Compare commits
11 Commits
main
...
nim-refact
Author | SHA1 | Date | |
---|---|---|---|
22d3966755 | |||
a7a9cf8620 | |||
c75438d409 | |||
eadf3946e7 | |||
2d44d2c328 | |||
3f3a6b286b | |||
bad288a24b | |||
bbd7952232 | |||
85f37373b6 | |||
c3a5b5f87f | |||
bdd6c03129 |
5
.gitignore
vendored
5
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
*.sw?
|
||||
.gradle/
|
||||
build/
|
||||
/wdiwtlt
|
||||
nimble.develop
|
||||
nimble.paths
|
||||
|
76
README.md
76
README.md
@ -1,76 +0,0 @@
|
||||
# What Do I Want To Listen To
|
||||
|
||||
## A simple, tag-based music library manager.
|
||||
|
||||
This project is born out of a frustration I had managing my music library.
|
||||
I have found playlists, genres, and other ways of organizing my music too
|
||||
restrictive and cumbersome to keep up with. Here are the main features I want
|
||||
out of music player, in order of priority:
|
||||
|
||||
### Implemented
|
||||
|
||||
* Song tagging.
|
||||
* Playlists.
|
||||
* Bookmarks (temporary song tag marking location in a playlist/album)
|
||||
* Read meta-data from ID3.
|
||||
|
||||
### TODO
|
||||
|
||||
* Web-based interface.
|
||||
* Mobile interface (could implement the Subsonic API on the back-end to be able
|
||||
to use pre-made mobile apps)
|
||||
* Transcoding on the fly.
|
||||
* Read songs from Amazon S3.
|
||||
* Stream from Amazon Cloudfront.
|
||||
|
||||
I have not found a music manager that gives me all of the above, so I'm
|
||||
writing my own.
|
||||
|
||||
## Overview
|
||||
|
||||
WDIWTLT is currently made up of two subprojects:
|
||||
|
||||
* `core`
|
||||
* `cli`
|
||||
|
||||
### `core`
|
||||
|
||||
`core` contains the data layer implementation, built with a lightweight ORM
|
||||
layer over JDBC, and common functionality for managing a media library.
|
||||
|
||||
### `cli`
|
||||
|
||||
`cli` is a command-line interface built using the WDIWTLT core and VLC for
|
||||
media playback.
|
||||
|
||||
## Install
|
||||
|
||||
The current version, 0.1.0, is an alpha version. The CLI client depends on
|
||||
[VLC](http://www.videolan.org/vlc/) and expects it to already be installed on
|
||||
the system.
|
||||
|
||||
To install the CLI client:
|
||||
|
||||
$ wget http://mvn.jdb-labs.com/repo/com/jdbernard/wdiwtlt-cli/0.1.0/wdiwtlt-cli-0.1.0.zip
|
||||
$ unzip wdiwtlt-cli-0.1.0.zip
|
||||
|
||||
The `wdiwtlt-cli` binary is located in `wdiwtlt-cli/bin`, which you can add to
|
||||
you `PATH` environment variable.
|
||||
|
||||
## Building From Source
|
||||
|
||||
The `wdiwtlt` project is written in [Groovy](http://www.groovy-lang.org/) uses
|
||||
the [Gradle](http://gradle.org) build tool. If you do not already have a Groovy
|
||||
environment installed you can do:
|
||||
|
||||
$ curl -s https://get.sdkman.io | bash
|
||||
$ source "$HOME/.sdkman/bin/sdkman-init.sh"
|
||||
$ yes | sdkman install groovy
|
||||
$ yes | sdkman install gradle
|
||||
|
||||
Once you have Groovy and Gradle, the `wdiwtlt` project can be built by invoking
|
||||
`gradle assembleDist`.
|
||||
|
||||
$ git clone https://git.jdb-labs.com/jdb/wdiwtlt.git
|
||||
$ cd wdiwtlt
|
||||
$ gradle assembleDist
|
25
build.gradle
25
build.gradle
@ -1,25 +0,0 @@
|
||||
allprojects {
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
mavenLocal()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'ch.raffael.pegdown-doclet:pegdown-doclet:1.2'
|
||||
}
|
||||
}
|
||||
|
||||
group = 'com.jdbernard'
|
||||
version = '0.1.3'
|
||||
|
||||
repositories {
|
||||
mavenLocal()
|
||||
mavenCentral()
|
||||
maven { url "https://dl.bintray.com/ijabz/maven" }
|
||||
maven { url "http://mvn.jdb-labs.com/repo" }
|
||||
}
|
||||
|
||||
apply plugin: 'maven'
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
apply plugin: 'java'
|
||||
apply plugin: 'groovy'
|
||||
apply plugin: 'ch.raffael.pegdown-doclet'
|
||||
apply plugin: 'application'
|
||||
|
||||
mainClassName = 'com.jdbernard.wdiwtlt.cli.CommandLineInterface'
|
||||
|
||||
dependencies {
|
||||
compile localGroovy()
|
||||
compile 'ch.qos.logback:logback-classic:1.1.3'
|
||||
compile 'ch.qos.logback:logback-core:1.1.3'
|
||||
compile 'org.slf4j:slf4j-api:1.7.14'
|
||||
compile 'com.offbytwo:docopt:0.6.0.20150202'
|
||||
compile 'com.jdbernard:jdb-util:4.4'
|
||||
compile 'jline:jline:2.12'
|
||||
compile project(":wdiwtlt-core")
|
||||
compile 'uk.co.caprica:vlcj:3.10.1'
|
||||
|
||||
testCompile 'junit:junit:4.12'
|
||||
|
||||
runtime 'net.java.dev.jna:jna:4.2.1'
|
||||
runtime 'net.java.dev.jna:jna-platform:4.2.1'
|
||||
}
|
@ -1,76 +0,0 @@
|
||||
# Selection
|
||||
|
||||
A selection can be a set of Artists, Albums, or songs. It is only ever one of
|
||||
these things. So you can select multiple artists, or multiple albums, or
|
||||
multiple songs.
|
||||
|
||||
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 {album, artist, file, playlist}
|
||||
|
||||
# Play Queue Management
|
||||
|
||||
enqueue selection
|
||||
enqueue <selection-criteria>
|
||||
|
||||
remove selection from queue
|
||||
remove <selection-criteria> from queue
|
||||
|
||||
play selection
|
||||
play bookmark <id | name>
|
||||
play <selection-criteria>
|
||||
|
||||
clear queue
|
||||
|
||||
# Tagging
|
||||
|
||||
tag <tag>...
|
||||
tag selection as <tag>...
|
||||
tag <selection-criteria> as <tag>...
|
||||
|
||||
# List
|
||||
|
||||
list <selection-criteria>
|
||||
list selected {albums, artists, files, playlists, tags}
|
||||
list playing {albums, artists, files, playlists, tags}
|
||||
|
||||
# Playlist management
|
||||
|
||||
create playlist named <playlist name>
|
||||
create playlist named <playlist name> from {selection, queue}
|
||||
copy playlist <id | name> as <new name>
|
||||
rename playlist <old name> to <new name>
|
||||
|
||||
add selection to playlist <id | name>
|
||||
add <selection-criteria> to playlist <id | name>
|
||||
|
||||
remove selection from playlist <id | name>
|
||||
remove <selection-criteria> from playlist <id | name>
|
||||
|
||||
clear playlist <id | name>
|
||||
|
||||
delete playlist <id | name>
|
||||
|
||||
# Bookmarking
|
||||
|
||||
create bookmark named <name>
|
||||
create bookmark named <name> on <playlist id | name> at <media file id | name>
|
||||
rename bookmark <old name> to <new name>
|
||||
delete bookmark <name | id>
|
||||
|
||||
# Transport Functions
|
||||
|
||||
play
|
||||
pause
|
||||
stop
|
||||
next <count>
|
||||
prev <count>
|
||||
ff <amount> <unit>
|
||||
rw <amount> <unit>
|
||||
jump <media file id | name>
|
||||
|
||||
# Misc
|
||||
|
||||
scan
|
||||
clear
|
@ -1,9 +0,0 @@
|
||||
package com.jdbernard.wdiwtlt.cli
|
||||
|
||||
public class CliErr extends Exception {
|
||||
public CliErr(String message) { super(message) }
|
||||
public CliErr(String message, Throwable t) { super(message, t) }
|
||||
public CliErr(Throwable t) { super(t) }
|
||||
|
||||
public static err(String msg) { throw new CliErr(msg) }
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -1,69 +0,0 @@
|
||||
package com.jdbernard.wdiwtlt.cli
|
||||
|
||||
import com.jdbernard.util.AnsiEscapeCodeSequence as ANSI
|
||||
|
||||
public class ScrollText {
|
||||
|
||||
public String text = ""
|
||||
public int maxWidth
|
||||
|
||||
private int scrollIdx
|
||||
private String curAnsiPrefix = ""
|
||||
|
||||
public void setMaxWidth(int max) {
|
||||
this.maxWidth = Math.max(max, 1) }
|
||||
|
||||
public void setText(String t) {
|
||||
this.text = t
|
||||
this.scrollIdx = Math.max(0, text.size() - 10) }
|
||||
|
||||
public String getNextScroll() {
|
||||
if (ANSI.strip(text).size() < maxWidth) return text
|
||||
|
||||
scrollIdx = (scrollIdx + 1) % text.size()
|
||||
|
||||
// If we're on the start of an ANSI escape sequence, skip past the end
|
||||
// of it.
|
||||
while (text[scrollIdx] == '\u001b') {
|
||||
def endIdx = text.findIndexOf(scrollIdx) {
|
||||
Character.isLetter(it.charAt(0)) }
|
||||
curAnsiPrefix = text[scrollIdx..endIdx]
|
||||
scrollIdx = (endIdx + 1) % text.size() }
|
||||
|
||||
int toWalk = maxWidth
|
||||
int endIdx = scrollIdx + 1
|
||||
int wrapIdx = 0
|
||||
|
||||
boolean inAnsiSeq = false
|
||||
while (toWalk > 0 && wrapIdx < scrollIdx) {
|
||||
def cur
|
||||
if (endIdx == text.size()) {
|
||||
cur = text[wrapIdx]
|
||||
|
||||
if (cur == '\u001b') inAnsiSeq = true
|
||||
|
||||
if (inAnsiSeq && Character.isLetter(cur.charAt(0)))
|
||||
inAnsiSeq = false
|
||||
|
||||
if (!inAnsiSeq) toWalk--
|
||||
wrapIdx++ }
|
||||
|
||||
else {
|
||||
cur = text[endIdx]
|
||||
|
||||
if (cur == '\u001b') inAnsiSeq = true
|
||||
|
||||
if (inAnsiSeq && Character.isLetter(cur.charAt(0)))
|
||||
inAnsiSeq = false
|
||||
|
||||
if (!inAnsiSeq) toWalk--
|
||||
endIdx++ } }
|
||||
|
||||
if (wrapIdx == 0) return curAnsiPrefix + text[scrollIdx..<endIdx]
|
||||
return new StringBuilder()
|
||||
.append(curAnsiPrefix)
|
||||
.append(text[scrollIdx..<endIdx])
|
||||
.append(" ").append(text[0..<wrapIdx]) }
|
||||
|
||||
public String toString() { return getNextScroll() }
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
package com.jdbernard.wdiwtlt.cli
|
||||
|
||||
public class UniversalNoopImplementation {
|
||||
Map methods = [:]
|
||||
|
||||
public def methodMissing(String name, def args) {
|
||||
if (methods[name]) return methods[name](*args) } }
|
@ -1,19 +0,0 @@
|
||||
import ch.qos.logback.core.*;
|
||||
import ch.qos.logback.core.encoder.*;
|
||||
import ch.qos.logback.core.read.*;
|
||||
import ch.qos.logback.core.rolling.*;
|
||||
import ch.qos.logback.core.status.*;
|
||||
import ch.qos.logback.classic.net.*;
|
||||
import ch.qos.logback.classic.encoder.PatternLayoutEncoder;
|
||||
|
||||
|
||||
appender("FILE", FileAppender) {
|
||||
file = "wdiwtlt.log"
|
||||
append = true
|
||||
encoder(PatternLayoutEncoder) {
|
||||
pattern = "%level %logger - %msg%n"
|
||||
}
|
||||
}
|
||||
|
||||
root(OFF)
|
||||
logger("com.jdbernard.wdiwtlt", WARN, ["FILE"])
|
4
config.nims
Normal file
4
config.nims
Normal file
@ -0,0 +1,4 @@
|
||||
# begin Nimble config (version 2)
|
||||
when withDir(thisDir(), system.fileExists("nimble.paths")):
|
||||
include "nimble.paths"
|
||||
# end Nimble config
|
@ -1,10 +0,0 @@
|
||||
## Database
|
||||
|
||||
Uses [db-migrate][http://db-migrate.readthedocs.org/en/latest/] to manage
|
||||
database migrations. Migration scripts live if `src/main/db`.
|
||||
|
||||
Database environment configuration lives in `database.json`.
|
||||
|
||||
To initialize a new database do:
|
||||
|
||||
db-migrate -m src/main/db up
|
@ -1,30 +0,0 @@
|
||||
buildscript {
|
||||
repositories {
|
||||
mavenLocal()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'ch.raffael.pegdown-doclet:pegdown-doclet:1.2'
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: 'java'
|
||||
apply plugin: 'groovy'
|
||||
apply plugin: 'ch.raffael.pegdown-doclet'
|
||||
|
||||
dependencies {
|
||||
compile localGroovy()
|
||||
compile 'ch.qos.logback:logback-classic:1.1.3'
|
||||
compile 'ch.qos.logback:logback-core:1.1.3'
|
||||
compile 'org.slf4j:slf4j-api:1.7.14'
|
||||
compile 'com.zaxxer:HikariCP:2.4.3'
|
||||
compile 'net.jthink:jaudiotagger:2.2.3'
|
||||
compile 'commons-codec:commons-codec:1.10'
|
||||
compile 'javax.persistence:persistence-api:1.0.2'
|
||||
|
||||
testCompile 'junit:junit:4.12'
|
||||
|
||||
runtime 'com.h2database:h2:1.4.192'
|
||||
runtime 'org.postgresql:postgresql:9.4.1207.jre7'
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
dataSourceClassName=org.h2.jdbcx.JdbcDataSource
|
||||
dataSource.url=jdbc:h2:/Users/jonathan/programs/wdiwtlt/db;DATABASE_TO_UPPER=FALSE;AUTO_SERVER=TRUE
|
||||
dataSource.user=sa
|
||||
dataSource.password=
|
||||
migrations.dir=src/main/sql/migrations
|
@ -1,93 +0,0 @@
|
||||
package com.jdbernard.wdiwtlt
|
||||
|
||||
import com.zaxxer.hikari.HikariConfig
|
||||
|
||||
public class ConfigWrapper {
|
||||
|
||||
public static final String LIBRARY_DIR_KEY = "library.dir"
|
||||
public static final String DB_CONFIG_FILE_KEY = "database.config.file"
|
||||
|
||||
Properties configProps
|
||||
Map<String, String> env = System.getenv()
|
||||
|
||||
public ConfigWrapper() {
|
||||
if (env.WDIWTLT_CONFIG_FILE) {
|
||||
if (tryLoadConfigFile(env.WDIWTLT_CONFIG_FILE)) {
|
||||
return } }
|
||||
|
||||
if (env.HOME &&
|
||||
tryLoadConfigFile(new File(env.HOME, ".wdiwtlt.properties"))) return
|
||||
|
||||
String userHome = System.getProperty('user.home')
|
||||
if (userHome &&
|
||||
tryLoadConfigFile(new File(userHome, '.wdiwtlt.properties'))) return
|
||||
|
||||
try {
|
||||
ConfigWrapper.getResourceAsStream("/com/jdbernard/wdiwtlt/db/default.properties")
|
||||
.withStream { tryLoadConfig(is) } }
|
||||
catch (Exception e) {} }
|
||||
|
||||
public ConfigWrapper(File propertiesFile) {
|
||||
this.configPropertiesFile = configPropertiesFile }
|
||||
|
||||
public String getLibraryRootPath() {
|
||||
// 1. Check config properties
|
||||
if (configProps && configProps[LIBRARY_DIR_KEY])
|
||||
return configProps[LIBRARY_DIR_KEY]
|
||||
|
||||
// 2. Check environment variable
|
||||
if (env.WDIWTLT_LIBRARY_DIR)
|
||||
return env.WDIWTLT_LIBRARY_DIR
|
||||
|
||||
return null }
|
||||
|
||||
public HikariConfig getHikariConfig() {
|
||||
Properties props = new Properties()
|
||||
|
||||
// 1. Check config properties for database config file
|
||||
if (configProps) {
|
||||
|
||||
// 1.1 Look for a reference to a dedicated config file
|
||||
if (configProps[DB_CONFIG_FILE_KEY]) {
|
||||
File cf = new File(configProps[DB_CONFIG_FILE_KEY])
|
||||
if (cf.exists() && cf.isFile())
|
||||
cf.withInputStream { props.load(it) } }
|
||||
|
||||
// 1.2 Look for prefixed properties in this config file
|
||||
else if (configProps["database.config.dataSourceClassName"]) {
|
||||
props.putAll(configProps
|
||||
.findAll { it.key.startsWith("database.config.") }
|
||||
.collectEntries { [it.key[16..-1], it.value] } ) } }
|
||||
|
||||
// 2. Look for environment variables
|
||||
if (!props) {
|
||||
if (env.WDIWTLT_DATASOURCE_CLASSNAME)
|
||||
props["dataSourceClassName"] = env.WDIWTLT_DATABASE_DATASOURCECLASSNAME
|
||||
|
||||
if (env.WDIWTLT_DATASOURCE_PROPERTIES) {
|
||||
props.putAll(env.WDIWTLT_DATASOURCE_PROPERTIES.split(":")
|
||||
.collect { it.split("=") }
|
||||
.collectEntries { ["dataSource.${it[0]}", it[1]] }) } }
|
||||
|
||||
// If properties exist, create and return HikariConfig
|
||||
if (props) return new HikariConfig(props)
|
||||
else return null }
|
||||
|
||||
private boolean tryLoadConfigFile(String configFilePath) {
|
||||
if (!configFilePath) return false
|
||||
return tryLoadConfigFile(new File(configFilePath)) }
|
||||
|
||||
private boolean tryLoadConfigFile(File configFile) {
|
||||
if (!configFile.exists() || !configFile.isFile()) return false
|
||||
|
||||
return configFile.withInputStream { tryLoadConfig(it) } }
|
||||
|
||||
private boolean tryLoadConfig(InputStream is) {
|
||||
try {
|
||||
Properties props = new Properties()
|
||||
props.load(is)
|
||||
this.configProps = props }
|
||||
catch (Exception e) { return false }
|
||||
|
||||
return true }
|
||||
}
|
@ -1,299 +0,0 @@
|
||||
package com.jdbernard.wdiwtlt
|
||||
|
||||
import com.jdbernard.wdiwtlt.db.DbApi
|
||||
import com.jdbernard.wdiwtlt.db.models.*
|
||||
|
||||
import java.sql.Timestamp
|
||||
import java.util.regex.Pattern
|
||||
|
||||
import org.apache.commons.codec.digest.DigestUtils
|
||||
import org.jaudiotagger.audio.AudioFile
|
||||
import org.jaudiotagger.audio.AudioFileIO
|
||||
import org.jaudiotagger.tag.Tag as JATag
|
||||
import org.jaudiotagger.tag.FieldKey
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
import static org.jaudiotagger.tag.FieldKey.*
|
||||
|
||||
public class MediaLibrary {
|
||||
|
||||
private static Logger logger = LoggerFactory.getLogger(MediaLibrary)
|
||||
|
||||
private static UPPERCASE_PATTERN = Pattern.compile(/(.)(\p{javaUpperCase})/)
|
||||
|
||||
@Delegate DbApi dbapi
|
||||
private File libraryRoot
|
||||
|
||||
public long autoDeletePeriod = 1000 * 60 * 60 * 24 * 7 // one week
|
||||
|
||||
public MediaLibrary(DbApi dbapi, File rootDir) {
|
||||
logger.debug("Creating a MediaLibrary rooted at: {}",
|
||||
rootDir.canonicalPath)
|
||||
|
||||
this.dbapi = dbapi
|
||||
this.libraryRoot = rootDir }
|
||||
|
||||
public void clean() {
|
||||
dbapi.removeEmptyAlbums()
|
||||
dbapi.removeEmptyArtists()
|
||||
dbapi.removeEmptyPlaylists()
|
||||
|
||||
Timestamp staleTS = new Timestamp(new Date().time - autoDeletePeriod)
|
||||
dbapi.withTransaction {
|
||||
def stalePlaylists = dbapi.getPlaylistsWhere(
|
||||
userCreated: false, lastUsedBefore: staleTS)
|
||||
stalePlaylists.each { dbapi.delete(it) } }
|
||||
dbapi.withTransaction {
|
||||
def staleBookmarks = dbapi.getBookmarksWhere(
|
||||
userCreated: false, lastUsedBefore: staleTS)
|
||||
staleBookmarks.each { dbapi.delete(it) } } }
|
||||
|
||||
public def rescanLibrary() {
|
||||
def results = [ total: 0, ignored: 0, new: 0, present: 0, absent: 0]
|
||||
|
||||
List<MediaFile> missingFiles = dbapi.getMediaFiles()
|
||||
List<MediaFile> foundFiles = []
|
||||
|
||||
Date startDate = new Date()
|
||||
libraryRoot.eachFileRecurse { file ->
|
||||
|
||||
if (!file.isFile()) return
|
||||
def mf = addFile(file)
|
||||
|
||||
results.total++
|
||||
if (!mf) results.ignored++
|
||||
else {
|
||||
foundFiles << mf
|
||||
if (missingFiles.contains(mf)) missingFiles.remove(mf)
|
||||
if (mf.dateAdded > startDate) results.new++ } }
|
||||
|
||||
foundFiles.each { mf ->
|
||||
if (!mf.presentLocally) {
|
||||
mf.presentLocally = true
|
||||
dbapi.update(mf) } }
|
||||
|
||||
missingFiles.each { mf ->
|
||||
if (mf.presentLocally) {
|
||||
mf.presentLocally = false
|
||||
dbapi.update(mf) } }
|
||||
|
||||
results.present = foundFiles.size()
|
||||
results.absent = missingFiles.size()
|
||||
|
||||
return results }
|
||||
|
||||
public MediaFile addFile(File f) {
|
||||
if (!f.exists() || !f.isFile()) {
|
||||
logger.info("Ignoring non-existant file: {}", f.canonicalPath)
|
||||
return null }
|
||||
|
||||
def relPath = getRelativePath(libraryRoot, f)
|
||||
MediaFile found = dbapi.getMediaFileByFilePath(relPath)
|
||||
|
||||
if (found) {
|
||||
logger.info(
|
||||
"Ignoring a media file I already know about: {}", relPath)
|
||||
return found }
|
||||
|
||||
MediaFile mf = new MediaFile()
|
||||
mf.filePath = relPath
|
||||
|
||||
// Hash the file
|
||||
mf.fileHash = f.withInputStream { DigestUtils.md5Hex(it) }
|
||||
|
||||
// Look for an entry for that hash
|
||||
found = dbapi.getMediaFileByFileHash(mf.fileHash)
|
||||
|
||||
if (found) {
|
||||
logger.info('Found a media file by hash in a new location. ' +
|
||||
"I'm updating my relative path to this file:\n\t{}\n\t\t--> {}.",
|
||||
found.filePath, mf.filePath)
|
||||
found.filePath = mf.filePath
|
||||
return dbapi.update(found) }
|
||||
|
||||
// Read in the media's tags
|
||||
def af
|
||||
try { af = AudioFileIO.read(f) }
|
||||
catch (Exception e) {
|
||||
logger.info("Ignoring a file because I can't " +
|
||||
"read the media tag info:\n\t{}\n\t{}",
|
||||
f.canonicalPath, e.localizedMessage)
|
||||
return null }
|
||||
|
||||
def fileTag = af.tag
|
||||
|
||||
mf.name = fileTag?.getFirst(TITLE)?.trim() ?: f.name
|
||||
mf.comment = fileTag?.getAll(COMMENT)?.collect { it.trim() }?.join('\n\n')
|
||||
mf.discNumber = fileTag?.getFirst(DISC_NO) ?: '1'
|
||||
mf.trackNumber = safeToInteger(fileTag?.getFirst(TRACK))
|
||||
|
||||
def folderParts = mf.filePath.split("[\\\\/]")[1..<-1] as LinkedList
|
||||
|
||||
// Find artist and album names (if any)
|
||||
def artistNames = fileTag?.getAll(ARTIST)?.collect { it.trim() }
|
||||
def albumNames = fileTag?.getAll(ALBUM)?.collect { it.trim() }
|
||||
|
||||
if (!artistNames) {
|
||||
mf.metaInfoSource = MediaFile.FILE_LOCATION
|
||||
artistNames = folderParts.size() >= 2 ? [folderParts[0]] : [] }
|
||||
|
||||
if (!albumNames) {
|
||||
mf.metaInfoSource = MediaFile.FILE_LOCATION
|
||||
albumNames = [folderParts.peekLast()] }
|
||||
|
||||
dbapi.withTransaction {
|
||||
dbapi.create(mf)
|
||||
associateWithArtistsAndAlbums(mf, artistNames, albumNames,
|
||||
safeToInteger(fileTag?.getFirst(YEAR))) }
|
||||
|
||||
return mf
|
||||
}
|
||||
|
||||
public def getByIdOrName(Class modelClass, String input) {
|
||||
def match
|
||||
|
||||
if (safeToUUID(input)) {
|
||||
match = dbapi.getById(modelClass, safeToUUID(input))
|
||||
if (match) match = [match] }
|
||||
|
||||
else {
|
||||
match = dbapi.getByIdLike(modelClass, input)
|
||||
if (!match) match = dbapi.getByName(modelClass, input)
|
||||
if (!match) match = dbapi.getLike(modelClass, ["name"], [input]) }
|
||||
return match }
|
||||
|
||||
public List<Artist> getArtistsByName(String name) {
|
||||
return [dbapi.getArtistByName(name)] ?: dbapi.getArtistsLikeName(name) }
|
||||
|
||||
public List<Album> getAlbumsByName(String name) {
|
||||
return dbapi.getAlbumsWhere(name: name) ?: dbapi.getAlbumsLikeName(name) }
|
||||
|
||||
public List<MediaFile> collectMediaFiles(List<Model> models) {
|
||||
if (!models) return []
|
||||
|
||||
return models.collectMany { m ->
|
||||
if (m.class == MediaFile) return [m]
|
||||
return dbapi.getMediaFilesWhere((idKeyFor(m.class)): m.id) }.findAll() }
|
||||
|
||||
public List<Artist> splitArtist(Artist toSplit, Pattern splitPattern) {
|
||||
return splitArtist(toSplit, pattern.split(toSplit.name)) }
|
||||
|
||||
public List<Artist> splitArtist(Artist toSplit, List<String> newNames) {
|
||||
def albums = dbapi.getAlbumsWhere(artistId: toSplit.id)
|
||||
def mediaFiles = dbapi.getMediaFilesByArtistId(toSplit.id)
|
||||
|
||||
toSplit.name = newNames[0]
|
||||
List<Artist> newArtists = newNames[1..-1].collect { new Artist(it) }
|
||||
newArtists.each { newArtist ->
|
||||
albums.each { dbapi.associate(newArtist, it) }
|
||||
mediaFiles.each { dbapi.associate(newArtist, it) } }
|
||||
|
||||
return [toSplit] + newArtists }
|
||||
|
||||
private void associateWithArtistsAndAlbums(MediaFile mf,
|
||||
List<String> artistNames, List<String> albumNames, Integer albumYear) {
|
||||
|
||||
// Find or create artists.
|
||||
def artists = artistNames.collect { artistName ->
|
||||
Artist artist = dbapi.getArtistByName(artistName)
|
||||
if (!artist) artist = dbapi.create(new Artist(name: artistName))
|
||||
return artist }
|
||||
|
||||
// Associate file with artists.
|
||||
artists.each { dbapi.associate(it, mf) }
|
||||
|
||||
// Find or create albums
|
||||
def albums = albumNames.collect { albumName ->
|
||||
Album album
|
||||
|
||||
// If we know what year the album was released we can use that to
|
||||
// narrow down the list of matching albums.
|
||||
if (albumYear != null) {
|
||||
// Look first to see if we already know about this album
|
||||
// associated with one of the artists for this piece.
|
||||
album = artists.inject(null, { foundAlbum, artist ->
|
||||
if (foundAlbum) return foundAlbum
|
||||
def cur = dbapi.getAlbumsWhere(name: albumName,
|
||||
year: albumYear, artistId: artist.id)
|
||||
return cur ? cur[0] : null })
|
||||
|
||||
// If we don't have it with one of the artists, see if we have
|
||||
// one that matches the name and year.
|
||||
if (!album) {
|
||||
def cur = dbapi.getAlbumsWhere(
|
||||
name: albumName, year: albumYear)
|
||||
album = cur ? cur[0] : null } }
|
||||
|
||||
else {
|
||||
album = artists.inject(null, { foundAlbum, artist ->
|
||||
if (foundAlbum) return foundAlbum
|
||||
def cur = dbapi.getAlbumsWhere(
|
||||
name: albumName, artistId: artist.id)
|
||||
return cur ? cur[0] : null })
|
||||
|
||||
if (!album) {
|
||||
def cur = dbapi.getAlbumsWhere(name: albumName)
|
||||
album = cur ? cur[0] : null } }
|
||||
|
||||
// We still can't find the album at all. We'll need to create it
|
||||
if (!album)
|
||||
album = dbapi.create(new Album(name: albumName, year: albumYear))
|
||||
|
||||
return album }
|
||||
|
||||
// Associate file with albums
|
||||
albums.each { dbapi.associate(it, mf) }
|
||||
|
||||
// Make sure we have association between all of the artists and albums.
|
||||
artists.each { artist ->
|
||||
def albumsForArtist = dbapi.getAlbumsWhere(artistId: artist.id)
|
||||
def albumsMissing = albums - albumsForArtist
|
||||
albumsMissing.each { album -> dbapi.associate(artist, album) } } }
|
||||
|
||||
/** #### `getRelativePath`
|
||||
* Given a parent path and a child path, assuming the child path is
|
||||
* contained within the parent path, return the relative path from the
|
||||
* parent to the child. */
|
||||
public static String getRelativePath(File parent, File child) {
|
||||
def parentPath = parent.canonicalPath.split("[\\\\/]")
|
||||
def childPath = child.canonicalPath.split("[\\\\/]")
|
||||
|
||||
/// If the parent path is longer it cannot contain the child path and
|
||||
/// we cannot construct a relative path without backtracking.
|
||||
if (parentPath.length > childPath.length) return ""
|
||||
|
||||
/// Compare the parent and child path up until the end of the parent
|
||||
/// path.
|
||||
int b = 0
|
||||
while (b < parentPath.length && parentPath[b] == childPath[b] ) b++
|
||||
|
||||
/// If we stopped before reaching the end of the parent path it must be
|
||||
/// that the paths do not match. The parent cannot contain the child and
|
||||
/// we cannot build a relative path without backtracking.
|
||||
if (b != parentPath.length) return ""
|
||||
return (['.'] + childPath[b..<childPath.length]).join('/') }
|
||||
|
||||
public static UUID safeToUUID(def val) {
|
||||
if (val == null) return null
|
||||
try { return UUID.fromString(val as String) }
|
||||
catch (IllegalArgumentException iae) { return null } }
|
||||
|
||||
public static Integer safeToInteger(def val) {
|
||||
if (val == null) return null
|
||||
try { return val.trim() as Integer }
|
||||
catch (NumberFormatException nfe) { return null } }
|
||||
|
||||
public static String uncapitalize(String s) {
|
||||
if (s == null) return null;
|
||||
if (s.length() < 2) return s.toLowerCase();
|
||||
return s[0].toLowerCase() + s[1..-1] }
|
||||
|
||||
public static String toEnglish(Class c) {
|
||||
return UPPERCASE_PATTERN.matcher(c.simpleName).
|
||||
replaceAll(/$1 $2/).toLowerCase() }
|
||||
|
||||
public static String idKeyFor(Class c) {
|
||||
return uncapitalize(c.simpleName) + 'Id'; }
|
||||
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -1,11 +0,0 @@
|
||||
package com.jdbernard.wdiwtlt.db.models;
|
||||
|
||||
public class Album extends Model {
|
||||
public String name;
|
||||
public Integer trackTotal;
|
||||
public Integer year;
|
||||
|
||||
public String toString() {
|
||||
if (year != null) return name + " (" + year + ")";
|
||||
else return name; }
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
package com.jdbernard.wdiwtlt.db.models;
|
||||
|
||||
public class Artist extends Model {
|
||||
public String name;
|
||||
|
||||
public String toString() { return name; }
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
package com.jdbernard.wdiwtlt.db.models;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.util.Date;
|
||||
import java.util.UUID;
|
||||
|
||||
public class Bookmark extends Model {
|
||||
public String name;
|
||||
public UUID playlistId;
|
||||
public UUID mediaFileId;
|
||||
public int playIndex = 0;
|
||||
public int playTimeMs = 0;
|
||||
public boolean userCreated;
|
||||
public Timestamp createdAt = new Timestamp(new Date().getTime());
|
||||
public Timestamp lastUsed = new Timestamp(new Date().getTime());
|
||||
|
||||
public String toString() { return name; }
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
package com.jdbernard.wdiwtlt.db.models;
|
||||
|
||||
public class Image extends Model {
|
||||
public String url;
|
||||
|
||||
public String toString() { return url; }
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
package com.jdbernard.wdiwtlt.db.models;
|
||||
|
||||
import java.util.Date;
|
||||
import java.sql.Timestamp;
|
||||
|
||||
public class MediaFile extends Model {
|
||||
public static final String TAG_INFO = "tag info";
|
||||
public static final String FILE_LOCATION = "file location";
|
||||
|
||||
public String name;
|
||||
public String discNumber;
|
||||
public Integer trackNumber;
|
||||
public int playCount = 0;
|
||||
public String filePath;
|
||||
public String fileHash;
|
||||
public String metaInfoSource = TAG_INFO;
|
||||
public Timestamp dateAdded = new Timestamp(new Date().getTime());
|
||||
public Timestamp lastPlayed;
|
||||
public boolean presentLocally = true;
|
||||
public String comment;
|
||||
|
||||
public String toString() {
|
||||
if (trackNumber != null) return trackNumber + " - " + name;
|
||||
else return name; }
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
package com.jdbernard.wdiwtlt.db.models;
|
||||
|
||||
import java.util.UUID;
|
||||
import javax.persistence.Entity;
|
||||
|
||||
@Entity
|
||||
public class Model implements Comparable<Model>, Cloneable {
|
||||
public UUID id;
|
||||
|
||||
public boolean equals(Object thatObj) {
|
||||
if (thatObj == null) return false;
|
||||
if (this.getClass() != thatObj.getClass()) return false;
|
||||
|
||||
Model that = (Model) thatObj;
|
||||
|
||||
if (this.id == null || that.id == null) return false;
|
||||
return this.id.equals(that.id); }
|
||||
|
||||
public int compareTo(Model that) {
|
||||
if (this.id == null) return -1;
|
||||
if (this.getClass() != that.getClass()) {
|
||||
return this.getClass().getSimpleName().compareTo(
|
||||
that.getClass().getSimpleName()); }
|
||||
|
||||
return this.id.compareTo(that.id); }
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
package com.jdbernard.wdiwtlt.db.models;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.util.Date;
|
||||
import java.util.UUID;
|
||||
|
||||
public class Playlist extends Model {
|
||||
public boolean userCreated = true;
|
||||
public String name;
|
||||
public int mediaFileCount = 0;
|
||||
public UUID copiedFromId = null;
|
||||
public Timestamp createdAt = new Timestamp(new Date().getTime());
|
||||
public Timestamp lastUsed = new Timestamp(new Date().getTime());
|
||||
|
||||
public String toString() {
|
||||
if (userCreated) return name;
|
||||
return name + " (auto)"; }
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
package com.jdbernard.wdiwtlt.db.models;
|
||||
|
||||
public class Tag extends Model {
|
||||
public String name;
|
||||
public String description = "";
|
||||
|
||||
public String toString() {
|
||||
if (description != null && description.length() > 0)
|
||||
return name + " (" + description + ")";
|
||||
else return name; }
|
||||
}
|
@ -1,112 +0,0 @@
|
||||
CREATE TABLE artists (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR UNIQUE NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX artists_name_idx ON artists(name);
|
||||
|
||||
CREATE TABLE albums (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR NOT NULL,
|
||||
year INTEGER,
|
||||
track_total INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX albums_name_idx ON albums(name);
|
||||
|
||||
CREATE TABLE media_files (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR NOT NULL,
|
||||
disc_number VARCHAR NOT NULL DEFAULT '1',
|
||||
track_number INTEGER,
|
||||
play_count INTEGER NOT NULL DEFAULT 0,
|
||||
file_path VARCHAR NOT NULL,
|
||||
file_hash VARCHAR NOT NULL,
|
||||
meta_info_source VARCHAR NOT NULL, -- 'tag' or 'filesystem'
|
||||
date_added TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
last_played TIMESTAMP,
|
||||
present_locally BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
comment VARCHAR DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX media_files_name_idx ON media_files(name);
|
||||
|
||||
CREATE TABLE artists_media_files (
|
||||
artist_id UUID NOT NULL REFERENCES artists(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
media_file_id UUID NOT NULL REFERENCES media_files(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
PRIMARY KEY (artist_id, media_file_id)
|
||||
);
|
||||
|
||||
CREATE TABLE albums_media_files (
|
||||
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
media_file_id UUID NOT NULL REFERENCES media_files(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
PRIMARY KEY (album_id, media_file_id)
|
||||
);
|
||||
|
||||
CREATE TABLE tags (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR UNIQUE NOT NULL,
|
||||
description VARCHAR NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE playlists (
|
||||
id UUID PRIMARY KEY,
|
||||
user_created BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
name VARCHAR NOT NULL,
|
||||
media_file_count INTEGER NOT NULL DEFAULT 0,
|
||||
copied_from_id UUID DEFAULT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
last_used TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE playlists_media_files (
|
||||
playlist_id UUID NOT NULL REFERENCES playlists(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
media_file_id UUID NOT NULL REFERENCES media_files(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (playlist_id, media_file_id, position),
|
||||
UNIQUE (playlist_id, position)
|
||||
);
|
||||
|
||||
CREATE TABLE bookmarks (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR,
|
||||
user_created BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
playlist_id UUID NOT NULL REFERENCES playlists(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
media_file_id UUID 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 TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
last_used TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX bookmarks_playlist_id_idx ON bookmarks (playlist_id);
|
||||
CREATE INDEX bookmarks_media_file_id_idx ON bookmarks (media_file_id);
|
||||
|
||||
CREATE TABLE media_files_tags (
|
||||
media_file_id UUID REFERENCES media_files(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
PRIMARY KEY (media_file_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE images (
|
||||
id UUID PRIMARY KEY,
|
||||
url VARCHAR
|
||||
);
|
||||
|
||||
CREATE TABLE artists_images (
|
||||
artist_id UUID REFERENCES artists (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
image_id UUID REFERENCES images (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
PRIMARY KEY (artist_id, image_id)
|
||||
);
|
||||
|
||||
CREATE TABLE albums_images (
|
||||
album_id UUID REFERENCES albums (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
image_id UUID REFERENCES images (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
PRIMARY KEY (album_id, image_id)
|
||||
);
|
||||
|
||||
CREATE TABLE artists_albums (
|
||||
artist_id UUID NOT NULL REFERENCES artists (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
album_id UUID NOT NULL REFERENCES albums (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
PRIMARY KEY (artist_id, album_id)
|
||||
);
|
@ -1,35 +0,0 @@
|
||||
import groovy.grape.Grape
|
||||
import groovy.sql.Sql
|
||||
Grape.grab(group: 'com.zaxxer', module: 'HikariCP', version: '2.4.3')
|
||||
Grape.grab(group: 'org.postgresql', module: 'postgresql', version: '9.4.1207.jre7')
|
||||
Grape.grab(group: 'commons-codec', module: 'commons-codec', version: '1.10')
|
||||
|
||||
import com.jdbernard.wdiwtlt.MediaLibrary
|
||||
import com.jdbernard.wdiwtlt.db.ORM
|
||||
import com.jdbernard.wdiwtlt.db.models.*
|
||||
import com.zaxxer.hikari.*
|
||||
import org.jaudiotagger.audio.*
|
||||
import org.jaudiotagger.tag.*
|
||||
import ch.qos.logback.classic.Level
|
||||
import ch.qos.logback.classic.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
rootLogger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME)
|
||||
rootLogger.level = Level.INFO
|
||||
|
||||
// myLogger = (Logger) LoggerFactory.getLogger("com.jdbernard.wdiwtlt")
|
||||
// myLogger.level = Level.DEBUG
|
||||
|
||||
config = new Properties()
|
||||
config.dataSourceClassName = "org.postgresql.ds.PGSimpleDataSource"
|
||||
config."dataSource.databaseName" = "wdiwtlt"
|
||||
config."dataSource.user" = "jonathan"
|
||||
config."dataSource.password" = ""
|
||||
|
||||
hcfg = new HikariConfig(config)
|
||||
hds = new HikariDataSource(hcfg)
|
||||
|
||||
db = new ORM(hds)
|
||||
|
||||
musicDir = new File('/Users/jonathan/Music')
|
||||
library = new MediaLibrary(db, musicDir)
|
@ -1,24 +0,0 @@
|
||||
apply plugin: 'groovy'
|
||||
apply plugin: 'war'
|
||||
apply plugin: "jetty"
|
||||
|
||||
dependencies {
|
||||
compile localGroovy()
|
||||
compile 'ch.qos.logback:logback-classic:1.1.3'
|
||||
compile 'ch.qos.logback:logback-core:1.1.3'
|
||||
compile 'org.slf4j:slf4j-api:1.7.14'
|
||||
compile 'com.impossibl.pgjdbc-ng:pgjdbc-ng:0.3'
|
||||
compile 'com.lambdaworks:scrypt:1.4.0'
|
||||
compile 'com.zaxxer:HikariCP-java6:2.3.2'
|
||||
compile 'javax:javaee-api:7.0'
|
||||
compile 'javax.ws.rs:javax.ws.rs-api:2.0.1'
|
||||
compile project(":wdiwtlt-core")
|
||||
|
||||
runtime 'com.fasterxml.jackson.jaxrs:jackson-jaxrs-json-provider:2.3.2'
|
||||
runtime 'org.glassfish.jersey.containers:jersey-container-servlet:2.16'
|
||||
runtime 'org.glassfish.jersey.media:jersey-media-json-jackson:2.16'
|
||||
|
||||
providedCompile 'javax.servlet:javax.servlet-api:3.1.0'
|
||||
|
||||
testCompile 'junit:junit:4.12'
|
||||
testRuntime 'com.h2database:h2:1.4.186' }
|
@ -1,12 +0,0 @@
|
||||
package com.jdbernard.nlsongs.rest;
|
||||
|
||||
import javax.ws.rs.NameBinding;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@NameBinding
|
||||
@Target({ ElementType.TYPE, ElementType.METHOD })
|
||||
@Retention(value = RetentionPolicy.RUNTIME)
|
||||
public @interface AllowCors {}
|
@ -1,30 +0,0 @@
|
||||
package com.jdbernard.nlsongs.rest;
|
||||
|
||||
import javax.annotation.Priority;
|
||||
import javax.ws.rs.Priorities;
|
||||
import javax.ws.rs.core.MultivaluedMap;
|
||||
import javax.ws.rs.container.ContainerRequestContext;
|
||||
import javax.ws.rs.container.ContainerResponseContext;
|
||||
import javax.ws.rs.container.ContainerResponseFilter;
|
||||
import javax.ws.rs.ext.Provider;
|
||||
|
||||
@Provider @AllowCors @Priority(Priorities.HEADER_DECORATOR)
|
||||
public class CorsResponseFilter implements ContainerResponseFilter {
|
||||
|
||||
@Override
|
||||
public void filter(ContainerRequestContext reqCtx,
|
||||
ContainerResponseContext respCtx) {
|
||||
|
||||
MultivaluedMap<String, Object> headers = respCtx.getHeaders();
|
||||
|
||||
headers.add("Access-Control-Allow-Origin",
|
||||
reqCtx.getHeaderString("Origin"));
|
||||
|
||||
headers.add("Access-Control-Allow-Methods",
|
||||
"GET, POST, PUT, DELETE, OPTIONS");
|
||||
|
||||
headers.add("Access-Control-Allow-Headers",
|
||||
reqCtx.getHeaderString("Access-Control-Request-Headers"));
|
||||
|
||||
}
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
package com.jdbernard.nlsongs.rest;
|
||||
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.Produces;
|
||||
|
||||
@Path("v1/ping") @AllowCors
|
||||
public class PingResource {
|
||||
|
||||
@GET
|
||||
@Produces("text/plain")
|
||||
public String ping() { return "pong"; } }
|
@ -1,8 +0,0 @@
|
||||
package com.jdbernard.nlsongs.servlet
|
||||
|
||||
import com.jdbernard.wdiwtlt.db.DbApi
|
||||
|
||||
public class WdiwtltContext {
|
||||
|
||||
public static DbApi dbapi
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
package com.jdbernard.nlsongs.servlet
|
||||
|
||||
import javax.servlet.ServletContext
|
||||
import javax.servlet.ServletContextEvent
|
||||
import javax.servlet.ServletContextListener
|
||||
|
||||
import com.jdbernard.wdiwtlt.db.DbApi
|
||||
|
||||
import com.zaxxer.hikari.HikariConfig
|
||||
import com.zaxxer.hikari.HikariDataSource
|
||||
|
||||
public final class WdiwtltContextListener implements ServletContextListener {
|
||||
|
||||
public void contextInitialized(ServletContextEvent event) {
|
||||
def context = event.servletContext
|
||||
|
||||
// Load the context configuration.
|
||||
Properties props = new Properties()
|
||||
WdiwtltContextListener.getResourceAsStream(
|
||||
context.getInitParameter('context.config.file')).withStream { is ->
|
||||
props.load(is) }
|
||||
|
||||
// Create the pooled data source
|
||||
HikariConfig hcfg = new HikariConfig(
|
||||
context.getInitParameter('datasource.config.file'))
|
||||
|
||||
HikariDataSource hds = new HikariDataSource(hcfg)
|
||||
|
||||
// Create the NLSonsDB instance.
|
||||
DbApi dbapi = new DbApi(hds)
|
||||
|
||||
context.setAttribute('dbapi', dbapi)
|
||||
WdiwtltContext.dbapi = dbapi }
|
||||
|
||||
public void contextDestroyed(ServletContextEvent event) {
|
||||
def context = event.servletContext
|
||||
|
||||
// Shutdown the DB API instance (it will shut down the data source).
|
||||
DbApi dbapi = context.getAttribute('dbapi')
|
||||
if (dbapi) dbapi.shutdown()
|
||||
|
||||
context.removeAttribute('dbapi') }
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
include 'core', 'cli', 'rest-server'
|
||||
|
||||
project(":core").name ="wdiwtlt-core"
|
||||
project(":cli").name = "wdiwtlt-cli"
|
334
src/main/nim/wdiwtlt.nim
Normal file
334
src/main/nim/wdiwtlt.nim
Normal file
@ -0,0 +1,334 @@
|
||||
import std/[json, options, os, paths, sequtils, strutils, terminal, times,
|
||||
unicode]
|
||||
import cliutils, docopt, namespaced_logging, mpv
|
||||
|
||||
import wdiwtlt/[cliconstants, commandbuffer, logging, medialibrary,
|
||||
messagebuffer, models, nonblockingio, scrolltext, usage]
|
||||
|
||||
type
|
||||
CliMode {.pure.} = enum Direct, Command
|
||||
|
||||
MessageBuffers* = ref object
|
||||
playing: MessageBuffer[ScrollText]
|
||||
status: MessageBuffer[ScrollText]
|
||||
longform: MessageBuffer[string]
|
||||
input: MessageBuffer[string]
|
||||
|
||||
History = ref object
|
||||
entries: seq[string]
|
||||
idx: int
|
||||
|
||||
CliContext = ref object
|
||||
cfg: CombinedConfig
|
||||
cmd: CommandBuffer
|
||||
curMediaFile: Option[MediaFile]
|
||||
lib: MediaLibrary
|
||||
log: Option[Logger]
|
||||
logs: seq[LogMessage]
|
||||
logThreshold: Level
|
||||
mode: CliMode
|
||||
msgs: MessageBuffers
|
||||
mpv: ptr handle
|
||||
msgDuration: Duration
|
||||
stop*: bool
|
||||
width*: int
|
||||
height*: int
|
||||
|
||||
var ctx {.threadvar.}: CliContext
|
||||
|
||||
const FRAME_DUR_MS = 200
|
||||
|
||||
proc initMpv(): ptr handle =
|
||||
result = mpv.create()
|
||||
|
||||
if result.isNil:
|
||||
raise newException(ValueError, "failed creating mpv context")
|
||||
|
||||
# Enable default key bindings, so the user can actually interact with
|
||||
# the player (and e.g. close the window).
|
||||
result.set_option("terminal", "no")
|
||||
result.set_option("input-default-bindings", "yes")
|
||||
result.set_option("input-vo-keyboard", "yes")
|
||||
result.set_option("osc", false)
|
||||
#result.set_property("volume", "50.0")
|
||||
discard result.request_log_messages("no")
|
||||
|
||||
# Done setting up options.
|
||||
check_error result.initialize()
|
||||
|
||||
|
||||
proc cleanup() =
|
||||
terminate_destroy(ctx.mpv)
|
||||
setNonBlockingTty(false)
|
||||
echo ""
|
||||
|
||||
|
||||
#[
|
||||
proc exitHandlerHook() {.noconv.} =
|
||||
# This is called when the user presses Ctrl+C
|
||||
# or when the program exits normally.
|
||||
terminate_destroy(ctx.mpv)
|
||||
echo ""
|
||||
quit(QuitSuccess)
|
||||
]#
|
||||
|
||||
proc initContext(args: Table[string, Value]): CliContext =
|
||||
var wdiwtltCfgFilename: string
|
||||
|
||||
let log = getLogger("wdiwtlt")
|
||||
|
||||
let cfgLocations =
|
||||
if args["--config"]: @[$args["--config"]]
|
||||
elif existsEnv("XDG_CONFIG_HOME"): @[getEnv("XDG_CONFIG_HOME") / "wdiwtlt.json"]
|
||||
else: @[$getEnv("HOME") / ".config" / "wdiwtlt.json" ]
|
||||
|
||||
try: wdiwtltCfgFilename = findConfigFile(".wdiwtlt.json", cfgLocations)
|
||||
except ValueError:
|
||||
log.error "could not find wdiwtlt config file: " & wdiwtltCfgFilename
|
||||
if not args["--config"]:
|
||||
wdiwtltCfgFilename = cfgLocations[0]
|
||||
|
||||
var cfgFile: File
|
||||
try:
|
||||
cfgFile = open(wdiwtltCfgFilename, fmWrite)
|
||||
cfgFile.write((%*{
|
||||
"libraryRoot":
|
||||
if args["--library-root"]:
|
||||
$args["--library-root"]
|
||||
else:
|
||||
getEnv("XDG_MUSIC_DIR", getEnv("HOME") / "music"),
|
||||
"dbPath": getEnv("XDG_DATA_HOME", (getEnv("HOME") / ".local/share")) / "wdiwtlt/db.sqlite",
|
||||
}).pretty)
|
||||
log.info "created sample config file at " & wdiwtltCfgFilename
|
||||
except CatchableError:
|
||||
log.error "could not write default .wdiwtlt.json to " &
|
||||
wdiwtltCfgFilename
|
||||
finally: close(cfgFile)
|
||||
|
||||
quit(QuitFailure)
|
||||
|
||||
log.debug("loading config from '$#'" % [wdiwtltCfgFilename])
|
||||
let cfg = initCombinedConfig(wdiwtltCfgFilename, args)
|
||||
|
||||
let width = min(terminalWidth(), cfg.getVal("maxWidth", "80").parseInt)
|
||||
let height = min(terminalHeight(), cfg.getVal("maxHeight", "60").parseInt)
|
||||
|
||||
let msgs = MessageBuffers(
|
||||
playing: initMessageBuffer[ScrollText](
|
||||
dims = BufferDimensions(width: width, height: 1, x: 0, y: 0),
|
||||
initialMsg = "Nothing playing"),
|
||||
status: initMessageBuffer[ScrollText](
|
||||
dims = BufferDimensions(width: width, height: 1, x: 0, y: 1),
|
||||
initialMsg = ""),
|
||||
input: initMessageBuffer[string](
|
||||
dims = BufferDimensions(width: width, height: 1, x: 0, y: 2),
|
||||
initialMsg = ""),
|
||||
longform: initMessageBuffer[string](
|
||||
dims = BufferDimensions(width: width, height: height - 3, x: 0, y: 4),
|
||||
initialMsg = ""))
|
||||
|
||||
result = CliContext(
|
||||
cfg: cfg,
|
||||
cmd: initCommandBuffer(msgs.input),
|
||||
curMediaFile: none(MediaFile),
|
||||
lib: initMediaLibrary(
|
||||
rootDir = getVal(cfg, "libraryRoot").Path,
|
||||
dbPath = getVal(cfg, "dbPath").Path),
|
||||
log: log,
|
||||
mode: CliMode.Direct,
|
||||
msgs: msgs,
|
||||
mpv: initMpv(),
|
||||
msgDuration: initDuration(seconds = 5, milliseconds = 0),
|
||||
stop: false,
|
||||
width: width,
|
||||
height: height)
|
||||
|
||||
if logService.isSome:
|
||||
let customLogAppender = initCustomLogAppender(doLogMessage =
|
||||
proc (msg: LogMessage): void = ctx.logs.add(msg))
|
||||
|
||||
logService.get.clearAppenders()
|
||||
logService.get.addAppender(customLogAppender)
|
||||
|
||||
|
||||
proc nextTick(ctx: var CliContext) =
|
||||
ctx.msgs.playing.nextTick
|
||||
ctx.msgs.status.nextTick
|
||||
ctx.msgs.longform.nextTick
|
||||
|
||||
|
||||
proc setMode(ctx: var CliContext, mode: CliMode) =
|
||||
ctx.mode = mode
|
||||
case mode
|
||||
|
||||
of CliMode.Direct:
|
||||
hideCursor()
|
||||
|
||||
of CliMode.Command:
|
||||
showCursor()
|
||||
ctx.cmd.setMode(EditMode.Insert)
|
||||
|
||||
|
||||
proc statusMsg(ctx: var CliContext, msg: string) =
|
||||
ctx.msgs.status.showMsg(msg, some(ctx.msgDuration))
|
||||
|
||||
|
||||
proc longMsg[T: string or seq[string]](ctx: var CliContext, msg: T) =
|
||||
ctx.msgs.longform.showMsg(msg, some(ctx.msgDuration))
|
||||
|
||||
|
||||
proc viewLogLoops(ctx: var CliContext) =
|
||||
let logLines = ctx.logs
|
||||
.filterIt(it.level > ctx.logThreshold)
|
||||
.mapIt("$# - [$#]: $#" % [$it.level, $it.scope, it.message])
|
||||
.join("\p")
|
||||
|
||||
ctx.msgs.longform.showMsg(logLines, none[Duration]())
|
||||
|
||||
var viewLogs = true
|
||||
while viewLogs:
|
||||
case getKeyAsync()
|
||||
of Key.Q: viewLogs = false
|
||||
of Key.K, Key.Up: ctx.msgs.longform.scrollLines(-1)
|
||||
of Key.J, Key.Down: ctx.msgs.longform.scrollLines(1)
|
||||
of Key.H, Key.Left: ctx.msgs.longform.scrollColumns(-1)
|
||||
of Key.L, Key.Right: ctx.msgs.longform.scrollColumns(1)
|
||||
of Key.PageUp: ctx.msgs.longform.scrollPages(-1)
|
||||
of Key.PageDown: ctx.msgs.longform.scrollPages(1)
|
||||
else: discard
|
||||
|
||||
sleep(FRAME_DUR_MS)
|
||||
|
||||
ctx.msgs.longform.clear()
|
||||
|
||||
proc scanLibrary(ctx: var CliContext) =
|
||||
ctx.statusMsg("Scanning media library...")
|
||||
let counts = ctx.lib.rescanLibrary()
|
||||
|
||||
var scanResults = @[
|
||||
"─".repeat(ctx.width),
|
||||
"Scan complete:",
|
||||
"\t$# files total." % [ $counts.total ]
|
||||
]
|
||||
|
||||
if counts.new > 0:
|
||||
scanResults.add("\t$# new files added." % [ $counts.new ])
|
||||
|
||||
if counts.ignored > 0:
|
||||
scanResults.add("\t$# files ignored." % [ $counts.ignored ])
|
||||
|
||||
if counts.absent > 0:
|
||||
scanResults.add(
|
||||
"\t$# files in the database but not stored locally." %
|
||||
[ $counts.absent ])
|
||||
|
||||
ctx.longMsg(scanResults.join("\p"))
|
||||
|
||||
proc processLogs(ctx: var CliContext, args: seq[string]) =
|
||||
case args[0]
|
||||
of "view": ctx.viewLogLoops()
|
||||
of "clear": ctx.logs = @[]
|
||||
of "set-threshold":
|
||||
if args.len < 2:
|
||||
ctx.longMsg(("missing $# argument to $#" %
|
||||
[ "log-threshold", "logs set-threshold" ]) &
|
||||
usageOf(@["logs", "set-threshold"]))
|
||||
|
||||
# TODO
|
||||
else: ctx.statusMsg("Unrecognized logs command: " & args[0])
|
||||
|
||||
|
||||
proc processCommand(ctx: var CliContext, command: string) =
|
||||
let parts = command.strip.split(' ')
|
||||
|
||||
case parts[0]
|
||||
of "scan": scanLibrary(ctx)
|
||||
of "q", "quit", "exit": ctx.stop = true
|
||||
of "logs": ctx.processLogs(parts[1..^1])
|
||||
else: statusMsg(ctx, "Unrecognized command: " & command)
|
||||
|
||||
|
||||
proc handleKey(ctx: var CliContext, key: Key) =
|
||||
if ctx.mode == CliMode.Direct:
|
||||
case key
|
||||
of Key.Q: ctx.stop = true
|
||||
of Key.Colon, Key.I: ctx.setMode(CliMode.Command)
|
||||
else: discard
|
||||
|
||||
elif ctx.mode == CliMode.Command:
|
||||
case key
|
||||
of Key.Enter:
|
||||
let command = $ctx.cmd
|
||||
ctx.cmd.clear
|
||||
ctx.cmd.mode = EditMode.Insert
|
||||
processCommand(ctx, command)
|
||||
of Key.Backspace: ctx.cmd.handleInput(Key.Backspace)
|
||||
of Key.Escape:
|
||||
if ctx.cmd.mode == EditMode.Command:
|
||||
ctx.setMode(CliMode.Direct)
|
||||
ctx.cmd.clear
|
||||
else: ctx.cmd.handleInput(Key.Escape)
|
||||
else: ctx.cmd.handleInput(key)
|
||||
|
||||
|
||||
proc mainLoop(ctx: var CliContext) =
|
||||
|
||||
hideCursor()
|
||||
var frame = 0
|
||||
|
||||
while not ctx.stop:
|
||||
|
||||
# This sleep below will be the primary driver of the frame rate of the
|
||||
# application (examples below):
|
||||
#
|
||||
# | frames/sec | sec/frame | ms/frame |
|
||||
# |------------|-----------|----------|
|
||||
# | 5.00 | 0.200 | 200 |
|
||||
# | 10.00 | 0.100 | 100 |
|
||||
# | 12.50 | 0.080 | 80 |
|
||||
# | 20.00 | 0.050 | 50 |
|
||||
# | 25.00 | 0.040 | 40 |
|
||||
# | 31.25 | 0.032 | 32 |
|
||||
# | 50.00 | 0.020 | 20 |
|
||||
# | 62.50 | 0.016 | 16 |
|
||||
#
|
||||
# Previously, when we were using illwill and rendering every frame we
|
||||
# targeted a faster FPS to allow for a responsive feeling to the command
|
||||
# input, etc. In this case, we still had a "logic update" period that was
|
||||
# lower than our rendering FPS. Specifically, we were targeting 60fps
|
||||
# rendering speed, but 5fps for logic updates (scrolling text, expiring
|
||||
# messages, etc.).
|
||||
#
|
||||
# Now that we are directly rendering (components of the UI render updates
|
||||
# themselves immediately) we don't need to render at 60fps to have a
|
||||
# responsive UI. Now we really only care about the frequency of logic
|
||||
# updates, so we're targeting 5fps and triggering our logic updates every
|
||||
# frame.
|
||||
handleKey(ctx, getKeyAsync())
|
||||
sleep(FRAME_DUR_MS)
|
||||
nextTick(ctx) # tick on every frame
|
||||
frame = (frame + 1) mod 1000 # use a max frame count to prevent overflow
|
||||
|
||||
|
||||
when isMainModule:
|
||||
|
||||
discard enableLogging()
|
||||
try:
|
||||
let args = docopt(USAGE, version=VERSION)
|
||||
|
||||
ctx = initContext(args)
|
||||
setNonBlockingTty(true)
|
||||
|
||||
mainLoop(ctx)
|
||||
|
||||
except Exception:
|
||||
let ex = getCurrentException()
|
||||
getLogger("wdiwtlt").error(%*{
|
||||
"msg": "Unhandled exception",
|
||||
"error": ex.msg,
|
||||
"trace": ex.getStackTrace()
|
||||
})
|
||||
cleanup()
|
||||
quit(QuitFailure)
|
||||
finally:
|
||||
cleanup()
|
18
src/main/nim/wdiwtlt/cliconstants.nim
Normal file
18
src/main/nim/wdiwtlt/cliconstants.nim
Normal file
@ -0,0 +1,18 @@
|
||||
const VERSION* = "0.2.0"
|
||||
|
||||
const USAGE* = "wdiwtlt v" & VERSION & """
|
||||
|
||||
Usage:
|
||||
wdiwtlt [options]
|
||||
wdiwtlt --version
|
||||
wdwtlt --help
|
||||
|
||||
Options:
|
||||
-c, --config <cfgPath> Path to the configuration file. Defaults to:
|
||||
~/.config/wdiwtlt/config.json
|
||||
|
||||
-L, --library-root <root-dir> The path to a local media library directory.
|
||||
|
||||
-D, --database-config <dbCfg> Path to the database file (SQLite3). Defaults to:
|
||||
~/.config/wdiwtlt/db.sqlite
|
||||
"""
|
247
src/main/nim/wdiwtlt/commandbuffer.nim
Normal file
247
src/main/nim/wdiwtlt/commandbuffer.nim
Normal file
@ -0,0 +1,247 @@
|
||||
import std/[options, strutils]
|
||||
|
||||
import ./[messagebuffer, nonblockingio, terminal, util]
|
||||
|
||||
type
|
||||
EditMode* {.pure.} = enum Command, Insert, Overwrite, Visual
|
||||
|
||||
Command = ref object
|
||||
buffer: string
|
||||
idx: int
|
||||
selectionStartIdx: Option[int]
|
||||
|
||||
CommandBuffer* = ref object
|
||||
history: seq[Command]
|
||||
mb: MessageBuffer[string]
|
||||
idx: int
|
||||
visibleIdx: int # first visible character in the cmd buffer
|
||||
mode*: EditMode
|
||||
|
||||
func initCommandBuffer*(mb: MessageBuffer[string]): CommandBuffer =
|
||||
result = CommandBuffer(
|
||||
history: @[Command(
|
||||
buffer: "",
|
||||
idx: 0,
|
||||
selectionStartIdx: none(int))],
|
||||
idx: 0,
|
||||
mb: mb,
|
||||
mode: Insert)
|
||||
|
||||
proc cur(cb: CommandBuffer): Command = cb.history[cb.idx]
|
||||
proc cur(cb: var CommandBuffer): Command = cb.history[cb.idx]
|
||||
|
||||
proc prev*(cb: var CommandBuffer) =
|
||||
cb.idx = clamp( cb.idx - 1, 0, cb.history.len - 1)
|
||||
|
||||
proc next*(cb: var CommandBuffer) =
|
||||
cb.idx = clamp( cb.idx + 1, 0, cb.history.len - 1)
|
||||
|
||||
proc insert*(cb: var CommandBuffer, s: string) =
|
||||
var cmd = cb.history[cb.idx]
|
||||
cmd.buffer = cmd.buffer[0..<cmd.idx] & s & cmd.buffer[cmd.idx..^1]
|
||||
cmd.idx += s.len
|
||||
|
||||
proc overwrite*(cb: var CommandBuffer, s: string) =
|
||||
var cmd = cb.history[cb.idx]
|
||||
if cmd.idx + s.len > cmd.buffer.len:
|
||||
cmd.buffer = cmd.buffer[0..<cmd.idx] & s
|
||||
else:
|
||||
cmd.buffer = cmd.buffer[0..<cmd.idx] & s & cmd.buffer[cmd.idx + s.len..^1]
|
||||
cmd.idx += s.len
|
||||
|
||||
proc write*(cb: var CommandBuffer, s: string) =
|
||||
if cb.mode == EditMode.Insert: cb.insert(s)
|
||||
elif cb.mode == EditMode.Overwrite: cb.overwrite(s)
|
||||
|
||||
proc delete*(cb: var CommandBuffer, backspace = true) =
|
||||
var cmd = cb.history[cb.idx]
|
||||
if backspace and cmd.idx > 0:
|
||||
cmd.buffer = cmd.buffer[0..<cmd.idx - 1] & cmd.buffer[cmd.idx..^1]
|
||||
cmd.idx -= 1
|
||||
elif not backspace and cmd.idx < cmd.buffer.len:
|
||||
cmd.buffer = cmd.buffer[0..<cmd.idx] & cmd.buffer[cmd.idx + 1..^1]
|
||||
|
||||
proc clear*(cb: var CommandBuffer) =
|
||||
cb.history.add(Command(
|
||||
buffer: "",
|
||||
idx: 0,
|
||||
selectionStartIdx: none(int)))
|
||||
cb.idx = cb.history.len - 1
|
||||
cb.visibleIdx = 0
|
||||
|
||||
proc left*(cb: var CommandBuffer) =
|
||||
cb.cur.idx = clamp(cb.cur.idx - 1, 0, cb.cur.buffer.len)
|
||||
|
||||
proc right*(cb: var CommandBuffer) =
|
||||
cb.cur.idx = clamp(cb.cur.idx + 1, 0, cb.cur.buffer.len)
|
||||
|
||||
proc toHome*(cb: var CommandBuffer) = cb.cur.idx = 0
|
||||
proc toEnd*(cb: var CommandBuffer) = cb.cur.idx = cb.cur.buffer.len
|
||||
|
||||
proc backWord*(cb: var CommandBuffer) =
|
||||
var cmd = cb.history[cb.idx]
|
||||
while cmd.idx > 0 and cmd.buffer[cmd.idx - 1] == ' ': cmd.idx -= 1
|
||||
while cmd.idx > 0 and cmd.buffer[cmd.idx - 1] != ' ': cmd.idx -= 1
|
||||
|
||||
proc forwardWord*(cb: var CommandBuffer) =
|
||||
var cmd = cb.history[cb.idx]
|
||||
while cmd.idx < cmd.buffer.len and cmd.buffer[cmd.idx] != ' ': cmd.idx += 1
|
||||
while cmd.idx < cmd.buffer.len and cmd.buffer[cmd.idx] == ' ': cmd.idx += 1
|
||||
|
||||
proc endOfWord*(cb: var CommandBuffer) =
|
||||
var cmd = cb.history[cb.idx]
|
||||
cmd.idx = clamp(cmd.idx + 1, 0, cmd.buffer.len - 1)
|
||||
while cmd.idx < cmd.buffer.len and cmd.buffer[cmd.idx] == ' ': cmd.idx += 1
|
||||
while cmd.idx < cmd.buffer.len - 1 and
|
||||
cmd.buffer[cmd.idx + 1] != ' ':
|
||||
cmd.idx += 1
|
||||
|
||||
func toChar(k: Key): char = cast[char](ord(k))
|
||||
|
||||
|
||||
proc setMode*(cb: var CommandBuffer, mode: EditMode) =
|
||||
cb.mode = mode
|
||||
case mode
|
||||
of EditMode.Insert: setCursorType(ctBarBlink)
|
||||
of EditMode.Overwrite: setCursorType(ctBlock)
|
||||
else: setCursorType(ctBlockBlink)
|
||||
|
||||
|
||||
func `$`*(cb: CommandBuffer): string =
|
||||
if cb.cur.selectionStartIdx.isNone:
|
||||
return cb.cur.buffer
|
||||
else:
|
||||
let selIdx = cb.cur.selectionStartIdx.get
|
||||
let curIdx = cb.cur.idx
|
||||
|
||||
if selIdx < curIdx:
|
||||
return cb.cur.buffer[0..<selIdx] &
|
||||
invert(cb.cur.buffer[selIdx..<curIdx]) &
|
||||
cb.cur.buffer[curIdx..^1]
|
||||
else:
|
||||
return cb.cur.buffer[0..<curIdx] &
|
||||
invert(cb.cur.buffer[curIdx..<selIdx]) &
|
||||
cb.cur.buffer[selIdx..^1]
|
||||
|
||||
func idx*(cb: CommandBuffer): int = cb.cur.idx
|
||||
|
||||
|
||||
proc render*(cb: var CommandBuffer) =
|
||||
write(cb.mb.dims.x, cb.mb.dims.y, ' '.repeat(cb.mb.dims.width))
|
||||
|
||||
let cmd = cb.cur
|
||||
var inputLine = cmd.buffer
|
||||
let spaceAvailable = cb.mb.dims.width - 2
|
||||
|
||||
# Check to see that the line entered fits entirely within the space
|
||||
# available to display it. We need two characters of space: one for the
|
||||
# colon and one for the cursor. If we don't have enough space, we need to
|
||||
# decide which portion of the line to display.
|
||||
if len(cmd.buffer) >= spaceAvailable:
|
||||
# If the user has moved the cursor left of our visible index, follow them
|
||||
# back
|
||||
if cmd.idx < cb.visibleIdx: cb.visibleIdx = cmd.idx
|
||||
|
||||
# If the user has moved right, push our index to the right to follow them
|
||||
elif (cmd.idx - cb.visibleIdx) > spaceAvailable:
|
||||
cb.visibleIdx = cmd.idx - spaceAvailable
|
||||
|
||||
# If the user has started backspacing (we are showing less that we
|
||||
# could), follow them back
|
||||
elif (len(cmd.buffer) - cb.visibleIdx) < spaceAvailable:
|
||||
cb.visibleIdx = len(cmd.buffer) - spaceAvailable
|
||||
|
||||
# Show the portion of the line starting at the visible index and ending
|
||||
# either with the end of the line or the end of the visible space
|
||||
# (whichever comes first). We need to check both because the user may
|
||||
# delete characters from the end of the line, which would make the
|
||||
# portion of the line we're currently showing shorter than the visible
|
||||
# space (don't try to access an index past the end of the string).
|
||||
inputLine = cmd.buffer[
|
||||
cb.visibleIdx ..<
|
||||
min(cb.visibleIdx + spaceAvailable, len(cmd.buffer))]
|
||||
|
||||
elif cb.visibleIdx > 0:
|
||||
# We know the line fits within the space available, but we're still
|
||||
# showing a subset of the line (probably because it was longer and the
|
||||
# user backspaced). Let's just reset and show the whole line now that we
|
||||
# can.
|
||||
cb.visibleIdx = 0
|
||||
|
||||
# TODO: implement VISUAL mode selection highlighting
|
||||
cb.mb.showMsg(":" & inputLine)
|
||||
setCursorPosition(
|
||||
cb.mb.dims.x + (cmd.idx - cb.visibleIdx) + 1,
|
||||
cb.mb.dims.y)
|
||||
|
||||
proc handleInput*(cb: var CommandBuffer, key: Key) =
|
||||
case cb.mode
|
||||
of EditMode.Insert, EditMode.Overwrite:
|
||||
case key
|
||||
of Key.Escape: cb.setMode(EditMode.Command)
|
||||
of Key.Backspace: cb.delete()
|
||||
of Key.Delete: cb.delete(backspace = false)
|
||||
of Key.Left: cb.left()
|
||||
of Key.Right: cb.right()
|
||||
of Key.Up: cb.prev()
|
||||
of Key.Down: cb.next()
|
||||
of Key.Home: cb.toHome()
|
||||
of Key.End: cb.toEnd()
|
||||
of Key.CtrlH: cb.backWord()
|
||||
of Key.CtrlL: cb.forwardWord()
|
||||
elif key >= Key.Space and key <= Key.Tilde: cb.write($toChar(key))
|
||||
else: discard
|
||||
|
||||
of EditMode.Command:
|
||||
case key
|
||||
of Key.Backspace: cb.delete()
|
||||
of Key.Delete, Key.X: cb.delete(backspace = false)
|
||||
of Key.Left, Key.H: cb.left()
|
||||
of Key.Right, Key.L: cb.right()
|
||||
of Key.Up, Key.J: cb.prev()
|
||||
of Key.Down, Key.K: cb.next()
|
||||
of Key.Home, Key.Zero: cb.toHome()
|
||||
of Key.End, Key.Dollar: cb.toEnd()
|
||||
of Key.B: cb.backWord()
|
||||
of Key.W: cb.forwardWord()
|
||||
of Key.E: cb.endOfWord()
|
||||
of Key.I: cb.setMode(EditMode.Insert)
|
||||
of Key.A:
|
||||
cb.cur.idx = clamp(cb.cur.idx + 1, 0, cb.cur.buffer.len)
|
||||
cb.setMode(EditMode.Insert)
|
||||
of Key.ShiftA:
|
||||
cb.cur.idx = cb.cur.buffer.len
|
||||
cb.setMode(EditMode.Insert)
|
||||
of Key.ShiftI:
|
||||
cb.cur.idx = 0
|
||||
cb.setMode(EditMode.Insert)
|
||||
of Key.ShiftR:
|
||||
cb.setMode(EditMode.Overwrite)
|
||||
of Key.V:
|
||||
cb.setMode(EditMode.Visual)
|
||||
cb.cur.selectionStartIdx = some(cb.cur.idx)
|
||||
else: discard
|
||||
|
||||
of EditMode.Visual:
|
||||
case key
|
||||
of Key.Escape:
|
||||
cb.setMode(EditMode.Command)
|
||||
cb.cur.selectionStartIdx = none(int)
|
||||
of Key.Backspace: cb.left()
|
||||
of Key.Left, Key.H: cb.left()
|
||||
of Key.Right, Key.L: cb.right()
|
||||
of Key.Up, Key.J:
|
||||
cb.cur.selectionStartIdx = none[int]()
|
||||
cb.prev()
|
||||
of Key.Down, Key.K:
|
||||
cb.cur.selectionStartIdx = none[int]()
|
||||
of Key.Home, Key.Zero: cb.toHome()
|
||||
of Key.End, Key.Dollar: cb.toEnd()
|
||||
of Key.B: cb.backWord()
|
||||
of Key.W: cb.forwardWord()
|
||||
of Key.V:
|
||||
cb.setMode(EditMode.Command)
|
||||
cb.cur.selectionStartIdx = none(int)
|
||||
else: discard
|
||||
|
||||
render(cb)
|
213
src/main/nim/wdiwtlt/db.nim
Normal file
213
src/main/nim/wdiwtlt/db.nim
Normal file
@ -0,0 +1,213 @@
|
||||
import std/[dirs, files, json, options, paths, sequtils, strutils, times]
|
||||
import db_connector/db_sqlite
|
||||
import waterpark/sqlite
|
||||
import fiber_orm, timeutils, uuids
|
||||
|
||||
export fiber_orm.NotFoundError
|
||||
export fiber_orm.PaginationParams
|
||||
export fiber_orm.PagedRecords
|
||||
export fiber_orm.enableDbLogging
|
||||
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 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])
|
||||
|
||||
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")
|
||||
|
||||
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.
|
||||
|
||||
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})
|
||||
|
||||
conn.exec(sql(query))
|
||||
|
||||
proc removeEmptyArtists*(db: WdiwtltDb) =
|
||||
## Remove artists that have no albums.
|
||||
|
||||
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})
|
||||
|
||||
conn.exec(sql(query))
|
||||
|
||||
proc removeEmptyPlaylists*(db: WdiwtltDb) =
|
||||
## Remove playlists that have no media files.
|
||||
|
||||
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})
|
||||
|
||||
conn.exec(sql(query))
|
39
src/main/nim/wdiwtlt/incremental_md5.nim
Normal file
39
src/main/nim/wdiwtlt/incremental_md5.nim
Normal file
@ -0,0 +1,39 @@
|
||||
import std/[paths, streams]
|
||||
import checksums/md5
|
||||
|
||||
proc fileToMD5*(filename: Path) : string =
|
||||
|
||||
const blockSize: int = 8192 # read files in 8KB chunnks
|
||||
var
|
||||
c: MD5Context
|
||||
d: MD5Digest
|
||||
fs: FileStream
|
||||
buffer: string
|
||||
|
||||
#read chunk of file, calling update until all bytes have been read
|
||||
try:
|
||||
fs = newFileStream(open($filename))
|
||||
|
||||
md5Init(c)
|
||||
buffer = fs.readStr(blockSize)
|
||||
|
||||
while buffer.len > 0:
|
||||
md5Update(c, buffer.cstring, buffer.len)
|
||||
buffer = fs.readStr(blockSize)
|
||||
|
||||
md5Final(c, d)
|
||||
|
||||
finally:
|
||||
if fs != nil:
|
||||
close(fs)
|
||||
|
||||
result = $d
|
||||
|
||||
when isMainModule:
|
||||
|
||||
if paramCount() > 0:
|
||||
let arguments = commandLineParams()
|
||||
echo("MD5: ", fileToMD5(arguments[0]))
|
||||
else:
|
||||
echo("Must pass filename.")
|
||||
quit(-1)
|
46
src/main/nim/wdiwtlt/logging.nim
Normal file
46
src/main/nim/wdiwtlt/logging.nim
Normal file
@ -0,0 +1,46 @@
|
||||
import std/[options, os, strutils, unicode]
|
||||
import namespaced_logging, zero_functional
|
||||
|
||||
from fiber_orm import enableDbLogging
|
||||
|
||||
export namespaced_logging
|
||||
|
||||
var logService* {.threadvar.}: Option[LogService]
|
||||
|
||||
proc enableLogging*(svc: LogService = initLogService(), debug = false): LogService =
|
||||
if not (svc.cfg.appenders --> exists(it of ConsoleLogAppender)):
|
||||
svc.addAppender(initConsoleLogAppender(threshold = lvlAll))
|
||||
|
||||
enableDbLogging(svc)
|
||||
logService = some(svc)
|
||||
result = svc
|
||||
|
||||
|
||||
proc configureLoggingThresholds*(debug = false) =
|
||||
if not logService.isSome: return
|
||||
let logSvc = logService.get
|
||||
|
||||
if debug:
|
||||
logSvc.cfg.rootLevel = Level.lvlDebug
|
||||
logSvc.cfg.loggers.add([
|
||||
LoggerConfig(name: "wdiwtlt", threshold: some(Level.lvlDebug)),
|
||||
LoggerConfig(name: "fiber_orm", threshold: some(Level.lvlDebug)),
|
||||
])
|
||||
else: logSvc.cfg.rootLevel = Level.lvlInfo
|
||||
|
||||
logSvc.reloadThreadState()
|
||||
|
||||
|
||||
proc enableLoggingByEnvVar*(envVar = "DEBUG"): void =
|
||||
if not logService.isSome: discard enableLogging()
|
||||
let val = getEnv(envVar, "false").toLower()
|
||||
|
||||
configureLoggingThresholds(
|
||||
"true".startsWith(val) or
|
||||
"yes".startsWith(val) or
|
||||
"on".startsWith(val) or
|
||||
val == "1")
|
||||
|
||||
proc getLogger*(name: string): Option[Logger] =
|
||||
if logService.isSome: return some(logService.get.getLogger(name))
|
||||
else: return none[Logger]()
|
247
src/main/nim/wdiwtlt/medialibrary.nim
Normal file
247
src/main/nim/wdiwtlt/medialibrary.nim
Normal file
@ -0,0 +1,247 @@
|
||||
import std/[dirs, files, json, options, paths, sequtils, strutils, sugar, times]
|
||||
import namespaced_logging, timeutils, uuids
|
||||
|
||||
import ./[db, incremental_md5, logging, models, taglib]
|
||||
|
||||
type
|
||||
MediaLibrary* = ref object
|
||||
db: WdiwtltDb
|
||||
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)
|
||||
removeEmptyArtists(lib.db)
|
||||
removeEmptyPlaylists(lib.db)
|
||||
|
||||
let expirationDate = now() - weeks(1)
|
||||
|
||||
let expiredPlaylists = lib.db.findPlaylistsWhere(
|
||||
"user_created = false AND last_used_at < ?",
|
||||
[expirationDate.formatIso8601])
|
||||
|
||||
let expiredBookmarks = lib.db.findBookmarksWhere(
|
||||
"user_created = false AND last_used_at < ?",
|
||||
[expirationDate.formatIso8601])
|
||||
|
||||
for p in expiredPlaylists.records: discard lib.db.deletePlaylist(p.id)
|
||||
for b in expiredBookmarks.records: discard lib.db.deleteBookmark(b.id)
|
||||
|
||||
proc findOrCreateArtist*(lib: MediaLibrary, name: string): Artist =
|
||||
## Find or create an artist record.
|
||||
|
||||
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
|
167
src/main/nim/wdiwtlt/messagebuffer.nim
Normal file
167
src/main/nim/wdiwtlt/messagebuffer.nim
Normal file
@ -0,0 +1,167 @@
|
||||
import std/[options, sequtils, strutils, times, wordwrap]
|
||||
|
||||
import ./[scrolltext, terminal, util]
|
||||
|
||||
type
|
||||
BufferDimensions* = object
|
||||
width*, height*, x*, y*: int
|
||||
|
||||
MessageBuffer*[T: ScrollText or string] = ref object
|
||||
content: T
|
||||
dims*: BufferDimensions
|
||||
dismissAfter: Option[DateTime]
|
||||
firstColumnIdx: int
|
||||
firstLineIdx: int
|
||||
|
||||
|
||||
proc getLinePortion(line: string, start: int, width: int): string =
|
||||
let cleanLine = stripFormatting(line)
|
||||
|
||||
if start == 0 and len(cleanLine) <= width:
|
||||
result = line & ' '.repeat(width - len(cleanLine))
|
||||
else:
|
||||
result = ansiAwareSubstring(line, start, width)
|
||||
|
||||
|
||||
proc render[T: ScrollText or string](mb: var MessageBuffer[T]) =
|
||||
hideCursor()
|
||||
when T is ScrollText: write(mb.dims.x, mb.dims.y, $mb.content)
|
||||
when T is string:
|
||||
let renderedContent = splitLines(mb.content)
|
||||
.mapIt(wrapWords(
|
||||
s = it,
|
||||
maxLineWidth = mb.dims.width,
|
||||
newLine = "\p").alignLeft(mb.dims.width))
|
||||
.mapIt(getLinePortion(it, mb.firstColumnIdx, mb.dims.width))
|
||||
.join("\p")
|
||||
let lastLineIdx = min(mb.firstLineIdx + mb.dims.height, len(renderedContent))
|
||||
write(mb.dims.x, mb.dims.y, renderedContent[mb.firstLineIdx..<lastLineIdx])
|
||||
showCursor()
|
||||
|
||||
|
||||
func variableMsgDuration[T: ScrollText or string](
|
||||
mb: MessageBuffer[T],
|
||||
initDur: Duration): Duration =
|
||||
when T is ScrollText:
|
||||
# text moves at 5 characters per second (192ms per character)
|
||||
result = initDur + initDuration(milliseconds = len($mb.content) * 192)
|
||||
when T is string:
|
||||
result = initDur + initDuration(milliseconds = len(splitLines(mb.content)) * 250)
|
||||
|
||||
|
||||
proc initMessageBuffer*[T: ScrollText or string](
|
||||
dims: BufferDimensions,
|
||||
initialMsg = ""): MessageBuffer[T] =
|
||||
|
||||
when T is ScrollText:
|
||||
result = MessageBuffer[T](
|
||||
content: initScrollText(initialMsg, max(dims.width, 1)),
|
||||
dims: BufferDimensions(
|
||||
width: max(dims.width, 1),
|
||||
height: 1,
|
||||
x: dims.x,
|
||||
y: dims.y),
|
||||
dismissAfter: none[DateTime](),
|
||||
firstColumnIdx: 0,
|
||||
firstLineIdx: 0)
|
||||
|
||||
when T is string:
|
||||
result = MessageBuffer[T](
|
||||
content: initialMsg,
|
||||
dims: BufferDimensions(
|
||||
width: max(dims.width, 1),
|
||||
height: dims.height,
|
||||
x: dims.x,
|
||||
y: dims.y),
|
||||
dismissAfter: none[DateTime](),
|
||||
firstColumnIdx: 0,
|
||||
firstLineIdx: 0)
|
||||
|
||||
render(result)
|
||||
|
||||
|
||||
proc showMsg*[T: ScrollText or string](
|
||||
mb: var MessageBuffer[T],
|
||||
msg: string,
|
||||
dur: Option[Duration] = none[Duration]()) =
|
||||
|
||||
when T is ScrollText: mb.content.text = msg
|
||||
when T is string: mb.content = msg
|
||||
|
||||
if dur.isSome:
|
||||
mb.dismissAfter = some(now() + mb.variableMsgDuration(dur.get))
|
||||
else:
|
||||
mb.dismissAfter = none[DateTime]()
|
||||
|
||||
render(mb)
|
||||
|
||||
|
||||
proc clear*[T: ScrollText or string](mb: var MessageBuffer[T]) =
|
||||
when T is ScrollText:
|
||||
let clearText = ' '.repeat(mb.dims.width)
|
||||
mb.content.text = ""
|
||||
|
||||
when T is string:
|
||||
let clearText = splitLines(mb.content)
|
||||
.mapIt(' '.repeat(mb.dims.width))
|
||||
.join("\p")
|
||||
|
||||
mb.content = ""
|
||||
|
||||
hideCursor()
|
||||
write(mb.dims.x, mb.dims.y, clearText)
|
||||
showCursor()
|
||||
mb.dismissAfter = none[DateTime]()
|
||||
|
||||
|
||||
#[ TODO: re-implement taking BufferDimensions if needed
|
||||
proc `maxWidth=`*[T: ScrollText or string](
|
||||
mb: var MessageBuffer[T],
|
||||
maxWidth: int) =
|
||||
mb.maxWidth = max(maxWidth, 1)
|
||||
|
||||
when T is ScrollText: mb.content.maxWidth = mb.maxWidth
|
||||
when T is string: mb.renderStringContent()
|
||||
]#
|
||||
|
||||
|
||||
proc nextTick*[T: ScrollText or string](mb: var MessageBuffer[T]) =
|
||||
|
||||
if mb.dismissAfter.isSome and now() > mb.dismissAfter.get:
|
||||
mb.clear()
|
||||
|
||||
when T is ScrollText:
|
||||
if mb.content.isScrolling:
|
||||
discard mb.content.nextTick()
|
||||
render(mb)
|
||||
|
||||
|
||||
func hasContent*[T: ScrollText or string](
|
||||
mb: MessageBuffer[T]): bool =
|
||||
when T is ScrollText: return len($mb.content) > 0
|
||||
when T is string: return len(mb.content) > 0
|
||||
|
||||
|
||||
proc scrollLines*[T](mb: var MessageBuffer[T], lines: int) =
|
||||
mb.firstLineIdx = clamp(
|
||||
mb.firstLineIdx + lines,
|
||||
0,
|
||||
max(0, len(mb.content) - mb.dims.height))
|
||||
render(mb)
|
||||
|
||||
proc scrollPages*[T](mb: var MessageBuffer[T], pages: int) =
|
||||
scrollLines(mb, pages * mb.dims.height)
|
||||
|
||||
|
||||
proc scrollColumns*(mb: var MessageBuffer[string], chars: int) =
|
||||
mb.firstColumnIdx = clamp(
|
||||
mb.firstColumnIdx + chars,
|
||||
0,
|
||||
max(0, len(mb.content) - mb.dims.width))
|
||||
render(mb)
|
||||
|
||||
proc resetScroll*(
|
||||
mb: var MessageBuffer[ScrollText]) =
|
||||
mb.firstColumnIdx = 0
|
||||
mb.firstLineIdx = 0
|
||||
render(mb)
|
56
src/main/nim/wdiwtlt/models.nim
Normal file
56
src/main/nim/wdiwtlt/models.nim
Normal file
@ -0,0 +1,56 @@
|
||||
import std/[options, times]
|
||||
import uuids
|
||||
|
||||
type
|
||||
Artist* = object
|
||||
id*: UUID
|
||||
name*: string
|
||||
|
||||
Album* = object
|
||||
id*: UUID
|
||||
name*: string
|
||||
year*: Option[int]
|
||||
trackTotal*: int
|
||||
|
||||
MediaFile* = object
|
||||
id*: UUID
|
||||
name*: string
|
||||
discNumber*: int
|
||||
trackNumber*: Option[int]
|
||||
playCount*: int
|
||||
filePath*: string
|
||||
fileHash*: string
|
||||
metaInfoSource*: string
|
||||
dateAdded*: DateTime
|
||||
lastPlayed*: Option[DateTime]
|
||||
presentLocally*: bool
|
||||
comment*: string
|
||||
|
||||
Tag* = object
|
||||
id*: UUID
|
||||
name*: string
|
||||
description*: string
|
||||
|
||||
Playlist* = object
|
||||
id*: UUID
|
||||
userCreated*: bool
|
||||
name*: string
|
||||
mediaFileCount*: int
|
||||
copiedFromId*: Option[UUID]
|
||||
createdAt*: DateTime
|
||||
lastUsedAt*: DateTime
|
||||
|
||||
Bookmark* = object
|
||||
id*: UUID
|
||||
name*: Option[string]
|
||||
userCreated*: bool
|
||||
playlistId*: UUID
|
||||
mediaFileId*: UUID
|
||||
playIndex*: int
|
||||
playTimeMs*: int
|
||||
createdAt*: DateTime
|
||||
lastUsedAt*: DateTime
|
||||
|
||||
Image* = object
|
||||
id*: UUID
|
||||
url*: string
|
263
src/main/nim/wdiwtlt/nonblockingio.nim
Normal file
263
src/main/nim/wdiwtlt/nonblockingio.nim
Normal file
@ -0,0 +1,263 @@
|
||||
# Adapted from [illwill](https://github.com/johnnovak/illwill) by John Novak
|
||||
import std/[posix, termios]
|
||||
|
||||
type
|
||||
Key* {.pure.} = enum ## Supported single key presses and key combinations
|
||||
None = (-1, "None"),
|
||||
|
||||
# Special ASCII characters
|
||||
CtrlA = (1, "CtrlA"),
|
||||
CtrlB = (2, "CtrlB"),
|
||||
CtrlC = (3, "CtrlC"),
|
||||
CtrlD = (4, "CtrlD"),
|
||||
CtrlE = (5, "CtrlE"),
|
||||
CtrlF = (6, "CtrlF"),
|
||||
CtrlG = (7, "CtrlG"),
|
||||
CtrlH = (8, "CtrlH"),
|
||||
Tab = (9, "Tab"), # Ctrl-I
|
||||
CtrlJ = (10, "CtrlJ"),
|
||||
CtrlK = (11, "CtrlK"),
|
||||
CtrlL = (12, "CtrlL"),
|
||||
Enter = (13, "Enter"), # Ctrl-M
|
||||
CtrlN = (14, "CtrlN"),
|
||||
CtrlO = (15, "CtrlO"),
|
||||
CtrlP = (16, "CtrlP"),
|
||||
CtrlQ = (17, "CtrlQ"),
|
||||
CtrlR = (18, "CtrlR"),
|
||||
CtrlS = (19, "CtrlS"),
|
||||
CtrlT = (20, "CtrlT"),
|
||||
CtrlU = (21, "CtrlU"),
|
||||
CtrlV = (22, "CtrlV"),
|
||||
CtrlW = (23, "CtrlW"),
|
||||
CtrlX = (24, "CtrlX"),
|
||||
CtrlY = (25, "CtrlY"),
|
||||
CtrlZ = (26, "CtrlZ"),
|
||||
Escape = (27, "Escape"),
|
||||
|
||||
CtrlBackslash = (28, "CtrlBackslash"),
|
||||
CtrlRightBracket = (29, "CtrlRightBracket"),
|
||||
|
||||
# Printable ASCII characters
|
||||
Space = (32, "Space"),
|
||||
ExclamationMark = (33, "ExclamationMark"),
|
||||
DoubleQuote = (34, "DoubleQuote"),
|
||||
Hash = (35, "Hash"),
|
||||
Dollar = (36, "Dollar"),
|
||||
Percent = (37, "Percent"),
|
||||
Ampersand = (38, "Ampersand"),
|
||||
SingleQuote = (39, "SingleQuote"),
|
||||
LeftParen = (40, "LeftParen"),
|
||||
RightParen = (41, "RightParen"),
|
||||
Asterisk = (42, "Asterisk"),
|
||||
Plus = (43, "Plus"),
|
||||
Comma = (44, "Comma"),
|
||||
Minus = (45, "Minus"),
|
||||
Dot = (46, "Dot"),
|
||||
Slash = (47, "Slash"),
|
||||
|
||||
Zero = (48, "Zero"),
|
||||
One = (49, "One"),
|
||||
Two = (50, "Two"),
|
||||
Three = (51, "Three"),
|
||||
Four = (52, "Four"),
|
||||
Five = (53, "Five"),
|
||||
Six = (54, "Six"),
|
||||
Seven = (55, "Seven"),
|
||||
Eight = (56, "Eight"),
|
||||
Nine = (57, "Nine"),
|
||||
|
||||
Colon = (58, "Colon"),
|
||||
Semicolon = (59, "Semicolon"),
|
||||
LessThan = (60, "LessThan"),
|
||||
Equals = (61, "Equals"),
|
||||
GreaterThan = (62, "GreaterThan"),
|
||||
QuestionMark = (63, "QuestionMark"),
|
||||
At = (64, "At"),
|
||||
|
||||
ShiftA = (65, "ShiftA"),
|
||||
ShiftB = (66, "ShiftB"),
|
||||
ShiftC = (67, "ShiftC"),
|
||||
ShiftD = (68, "ShiftD"),
|
||||
ShiftE = (69, "ShiftE"),
|
||||
ShiftF = (70, "ShiftF"),
|
||||
ShiftG = (71, "ShiftG"),
|
||||
ShiftH = (72, "ShiftH"),
|
||||
ShiftI = (73, "ShiftI"),
|
||||
ShiftJ = (74, "ShiftJ"),
|
||||
ShiftK = (75, "ShiftK"),
|
||||
ShiftL = (76, "ShiftL"),
|
||||
ShiftM = (77, "ShiftM"),
|
||||
ShiftN = (78, "ShiftN"),
|
||||
ShiftO = (79, "ShiftO"),
|
||||
ShiftP = (80, "ShiftP"),
|
||||
ShiftQ = (81, "ShiftQ"),
|
||||
ShiftR = (82, "ShiftR"),
|
||||
ShiftS = (83, "ShiftS"),
|
||||
ShiftT = (84, "ShiftT"),
|
||||
ShiftU = (85, "ShiftU"),
|
||||
ShiftV = (86, "ShiftV"),
|
||||
ShiftW = (87, "ShiftW"),
|
||||
ShiftX = (88, "ShiftX"),
|
||||
ShiftY = (89, "ShiftY"),
|
||||
ShiftZ = (90, "ShiftZ"),
|
||||
|
||||
LeftBracket = (91, "LeftBracket"),
|
||||
Backslash = (92, "Backslash"),
|
||||
RightBracket = (93, "RightBracket"),
|
||||
Caret = (94, "Caret"),
|
||||
Underscore = (95, "Underscore"),
|
||||
GraveAccent = (96, "GraveAccent"),
|
||||
|
||||
A = (97, "A"),
|
||||
B = (98, "B"),
|
||||
C = (99, "C"),
|
||||
D = (100, "D"),
|
||||
E = (101, "E"),
|
||||
F = (102, "F"),
|
||||
G = (103, "G"),
|
||||
H = (104, "H"),
|
||||
I = (105, "I"),
|
||||
J = (106, "J"),
|
||||
K = (107, "K"),
|
||||
L = (108, "L"),
|
||||
M = (109, "M"),
|
||||
N = (110, "N"),
|
||||
O = (111, "O"),
|
||||
P = (112, "P"),
|
||||
Q = (113, "Q"),
|
||||
R = (114, "R"),
|
||||
S = (115, "S"),
|
||||
T = (116, "T"),
|
||||
U = (117, "U"),
|
||||
V = (118, "V"),
|
||||
W = (119, "W"),
|
||||
X = (120, "X"),
|
||||
Y = (121, "Y"),
|
||||
Z = (122, "Z"),
|
||||
|
||||
LeftBrace = (123, "LeftBrace"),
|
||||
Pipe = (124, "Pipe"),
|
||||
RightBrace = (125, "RightBrace"),
|
||||
Tilde = (126, "Tilde"),
|
||||
Backspace = (127, "Backspace"),
|
||||
|
||||
# Special characters with virtual keycodes
|
||||
Up = (1001, "Up"),
|
||||
Down = (1002, "Down"),
|
||||
Right = (1003, "Right"),
|
||||
Left = (1004, "Left"),
|
||||
Home = (1005, "Home"),
|
||||
Insert = (1006, "Insert"),
|
||||
Delete = (1007, "Delete"),
|
||||
End = (1008, "End"),
|
||||
PageUp = (1009, "PageUp"),
|
||||
PageDown = (1010, "PageDown"),
|
||||
|
||||
F1 = (1011, "F1"),
|
||||
F2 = (1012, "F2"),
|
||||
F3 = (1013, "F3"),
|
||||
F4 = (1014, "F4"),
|
||||
F5 = (1015, "F5"),
|
||||
F6 = (1016, "F6"),
|
||||
F7 = (1017, "F7"),
|
||||
F8 = (1018, "F8"),
|
||||
F9 = (1019, "F9"),
|
||||
F10 = (1020, "F10"),
|
||||
F11 = (1021, "F11"),
|
||||
F12 = (1022, "F12"),
|
||||
|
||||
Mouse = (5000, "Mouse")
|
||||
|
||||
const
|
||||
KEYS_D = [Key.Up, Key.Down, Key.Right, Key.Left, Key.None, Key.End, Key.None, Key.Home]
|
||||
KEYS_E = [Key.Delete, Key.End, Key.PageUp, Key.PageDown, Key.Home, Key.End]
|
||||
KEYS_F = [Key.F1, Key.F2, Key.F3, Key.F4, Key.F5, Key.None, Key.F6, Key.F7, Key.F8]
|
||||
KEYS_G = [Key.F9, Key.F10, Key.None, Key.F11, Key.F12]
|
||||
|
||||
{.push warning[HoleEnumConv]:off.}
|
||||
|
||||
func toKey(c: int): Key =
|
||||
try:
|
||||
result = Key(c)
|
||||
except RangeDefect: # ignore unknown keycodes
|
||||
result = Key.None
|
||||
|
||||
{.pop}
|
||||
|
||||
proc setNonBlockingTty*(enabled: bool) =
|
||||
var ttyState: Termios
|
||||
|
||||
# get the terminal state
|
||||
discard tcGetAttr(STDIN_FILENO, ttyState.addr)
|
||||
|
||||
if enabled:
|
||||
# turn off canonical mode & echo
|
||||
ttyState.c_lflag = ttyState.c_lflag and not Cflag(ICANON or ECHO)
|
||||
|
||||
# minimum of number input read
|
||||
ttyState.c_cc[VMIN] = 0.char
|
||||
|
||||
else:
|
||||
# turn on canonical mode & echo
|
||||
ttyState.c_lflag = ttyState.c_lflag or ICANON or ECHO
|
||||
|
||||
# set the terminal attributes.
|
||||
discard tcSetAttr(STDIN_FILENO, TCSANOW, ttyState.addr)
|
||||
|
||||
proc parseStdin[T](input: T): Key =
|
||||
var ch1, ch2, ch3, ch4, ch5: char
|
||||
result = Key.None
|
||||
if read(input, ch1.addr, 1) > 0:
|
||||
case ch1
|
||||
of '\e':
|
||||
if read(input, ch2.addr, 1) > 0:
|
||||
if ch2 == 'O' and read(input, ch3.addr, 1) > 0:
|
||||
if ch3 in "ABCDFH":
|
||||
result = KEYS_D[int(ch3) - int('A')]
|
||||
elif ch3 in "PQRS":
|
||||
result = KEYS_F[int(ch3) - int('P')]
|
||||
elif ch2 == '[' and read(input, ch3.addr, 1) > 0:
|
||||
if ch3 in "ABCDFH":
|
||||
result = KEYS_D[int(ch3) - int('A')]
|
||||
elif ch3 in "PQRS":
|
||||
result = KEYS_F[int(ch3) - int('P')]
|
||||
elif ch3 == '1' and read(input, ch4.addr, 1) > 0:
|
||||
if ch4 == '~':
|
||||
result = Key.Home
|
||||
elif ch4 in "12345789" and read(input, ch5.addr, 1) > 0 and ch5 == '~':
|
||||
result = KEYS_F[int(ch4) - int('1')]
|
||||
elif ch3 == '2' and read(input, ch4.addr, 1) > 0:
|
||||
if ch4 == '~':
|
||||
result = Key.Insert
|
||||
elif ch4 in "0134" and read(input, ch5.addr, 1) > 0 and ch5 == '~':
|
||||
result = KEYS_G[int(ch4) - int('0')]
|
||||
elif ch3 in "345678" and read(input, ch4.addr, 1) > 0 and ch4 == '~':
|
||||
result = KEYS_E[int(ch3) - int('3')]
|
||||
else:
|
||||
discard # if cannot parse full seq it is discarded
|
||||
else:
|
||||
discard # if cannot parse full seq it is discarded
|
||||
else:
|
||||
result = Key.Escape
|
||||
of '\n':
|
||||
result = Key.Enter
|
||||
of '\b':
|
||||
result = Key.Backspace
|
||||
else:
|
||||
result = toKey(int(ch1))
|
||||
|
||||
proc kbhit(ms: int): cint =
|
||||
var tv: Timeval
|
||||
tv.tv_sec = Time(ms div 1000)
|
||||
tv.tv_usec = 1000 * (int32(ms) mod 1000) # int32 because of macos
|
||||
|
||||
var fds: TFdSet
|
||||
FD_ZERO(fds)
|
||||
FD_SET(STDIN_FILENO, fds)
|
||||
discard select(STDIN_FILENO+1, fds.addr, nil, nil, tv.addr)
|
||||
return FD_ISSET(STDIN_FILENO, fds)
|
||||
|
||||
proc getKeyAsync*(ms: int = 0): Key =
|
||||
result = Key.None
|
||||
if kbhit(ms) > 0:
|
||||
result = parseStdin(cint(STDIN_FILENO))
|
64
src/main/nim/wdiwtlt/scrolltext.nim
Normal file
64
src/main/nim/wdiwtlt/scrolltext.nim
Normal file
@ -0,0 +1,64 @@
|
||||
import std/strutils
|
||||
|
||||
type ScrollText* = ref object
|
||||
text: string
|
||||
maxWidth: int
|
||||
scrollIdx: int # Current index of the first character to show.
|
||||
lastRender: string
|
||||
|
||||
const endBufLen = 4
|
||||
|
||||
|
||||
proc render(st: ScrollText): string =
|
||||
if st.text.len <= st.maxWidth:
|
||||
return st.text
|
||||
|
||||
if st.scrollIdx == 0: return st.text[0..<st.maxWidth]
|
||||
elif st.scrollIdx >= st.text.len:
|
||||
return ' '.repeat(endBufLen - (st.scrollIdx - st.text.len)) &
|
||||
st.text[0..<st.maxWidth - 1]
|
||||
elif st.scrollIdx + st.maxWidth < st.text.len:
|
||||
return st.text[st.scrollIdx..<(st.scrollIdx + st.maxWidth)]
|
||||
else:
|
||||
return st.text[st.scrollIdx..<st.text.len] & ' '.repeat(endBufLen) &
|
||||
st.text[0..<max(0, (st.scrollIdx + st.maxWidth - st.text.len - 1))]
|
||||
|
||||
|
||||
proc `text=`*(st: var ScrollText, text: string) =
|
||||
st.text = text
|
||||
st.scrollIdx = text.len
|
||||
st.lastRender = render(st)
|
||||
|
||||
|
||||
proc `maxWidth=`*(st: var ScrollText, maxWidth: int) =
|
||||
st.maxWidth = max(maxWidth, 1)
|
||||
st.lastRender = render(st)
|
||||
|
||||
|
||||
proc initScrollText*(text: string, maxWidth: int): ScrollText =
|
||||
result = ScrollText(
|
||||
text: text,
|
||||
maxWidth: max(1, maxWidth),
|
||||
scrollIdx: 0)
|
||||
|
||||
result.lastRender = render(result)
|
||||
|
||||
|
||||
proc nextTick*(st: var ScrollText): string =
|
||||
|
||||
st.lastRender = render(st)
|
||||
|
||||
# Advance the scroll index by one.
|
||||
st.scrollIdx = (st.scrollIdx + 1) mod (st.text.len + endBufLen)
|
||||
|
||||
return st.lastRender
|
||||
|
||||
|
||||
func isScrolling*(st: ScrollText): bool =
|
||||
return st.text.len > st.maxWidth
|
||||
|
||||
|
||||
func `$`*(st: ScrollText): string = st.lastRender
|
||||
|
||||
|
||||
converter toString*(st: ScrollText): string = st.lastRender
|
167
src/main/nim/wdiwtlt/taglib.nim
Normal file
167
src/main/nim/wdiwtlt/taglib.nim
Normal file
@ -0,0 +1,167 @@
|
||||
import std/[paths, strutils]
|
||||
|
||||
{.passl: "-ltag_c".}
|
||||
{.passc: "-ltag_c".}
|
||||
|
||||
type
|
||||
TagLibFileType* {.size: sizeof(cint).} = enum
|
||||
MPEG, OggVorbis, FLAC, MPC, OggFlac, WavPack, Speex, TrueAudio, MP4, ASF,
|
||||
AIFF, WAV, APE, IT, Mod, S3M, XM, Opus, DSF, DSDIFF, SHORTEN
|
||||
|
||||
TagLibID3v2Encoding* {.size: sizeof(cint).} = enum
|
||||
Latin1, UTF16, UTF16BE, UTF8, Encoding
|
||||
|
||||
CFile = pointer
|
||||
CTag* = pointer
|
||||
CAudioProperties = pointer
|
||||
|
||||
TagLibFIle* = object
|
||||
path*: Path
|
||||
cfile*: CFile
|
||||
tag*: CTag
|
||||
ap*: CAudioProperties
|
||||
|
||||
{.push importc.}
|
||||
{.push cdecl.}
|
||||
proc taglib_set_strings_unicode*(unicode: cint)
|
||||
proc taglib_set_string_management_enabled*(enabled: cint)
|
||||
proc taglib_free*(p: pointer)
|
||||
|
||||
proc taglib_file_new(filename: cstring): CFile
|
||||
proc taglib_file_new_type(filename: cstring, `type`: TagLibFileType): CFile
|
||||
proc taglib_file_free(file: CFile)
|
||||
proc taglib_file_is_valid(file: CFile): cint
|
||||
proc taglib_file_tag(file: CFile): CTag
|
||||
proc taglib_file_audioproperties(file: CFile): CAudioProperties
|
||||
proc taglib_file_save(file: CFile): cint
|
||||
|
||||
proc taglib_tag_title(tag: CTag): cstring
|
||||
proc taglib_tag_artist(tag: CTag): cstring
|
||||
proc taglib_tag_album(tag: CTag): cstring
|
||||
proc taglib_tag_comment(tag: CTag): cstring
|
||||
proc taglib_tag_genre(tag: CTag): cstring
|
||||
proc taglib_tag_year(tag: CTag): cuint
|
||||
proc taglib_tag_track(tag: CTag): cuint
|
||||
proc taglib_tag_set_title(tag: CTag, title: cstring)
|
||||
proc taglib_tag_set_artist(tag: CTag, artist: cstring)
|
||||
proc taglib_tag_set_album(tag: CTag, album: cstring)
|
||||
proc taglib_tag_set_comment(tag: CTag, comment: cstring)
|
||||
proc taglib_tag_set_genre(tag: CTag, genre: cstring)
|
||||
proc taglib_tag_set_year(tag: CTag, year: cuint)
|
||||
proc taglib_tag_set_track(tag: CTag, year: cuint)
|
||||
proc taglib_tag_free_strings()
|
||||
|
||||
proc taglib_audioproperties_length(ap: CAudioProperties): cint
|
||||
proc taglib_audioproperties_bitrate(ap: CAudioProperties): cint
|
||||
proc taglib_audioproperties_samplerate(ap: CAudioProperties): cint
|
||||
proc taglib_audioproperties_channels(ap: CAudioProperties): cint
|
||||
|
||||
proc taglib_id3v2_set_default_text_encoding(encoding: TagLibID3v2Encoding)
|
||||
|
||||
proc taglib_property_set(file: CFile, prop: cstring, value: cstring)
|
||||
proc taglib_property_set_append(file: CFile, prop: cstring, value: cstring)
|
||||
#proc taglib_property_keys(file: CFile): cstring[]
|
||||
proc taglib_property_get(file: CFile, prop: cstring): cstring
|
||||
proc taglib_property_free(props: pointer)
|
||||
|
||||
{.pop.} # cdecl
|
||||
{.pop.} # importc
|
||||
|
||||
taglib_set_strings_unicode(1)
|
||||
|
||||
proc initTagLibFile(path: Path, cfile: CFile): TagLibFile =
|
||||
if cfile.isNil:
|
||||
raise newException(IOError, "Failed to open file: " & $path)
|
||||
|
||||
if taglib_file_is_valid(cfile) < 0:
|
||||
taglib_file_free(cfile)
|
||||
raise newException(IOError, "Invalid TagLib file: " & $path)
|
||||
|
||||
result.path = path
|
||||
result.cfile = cfile
|
||||
result.tag = taglib_file_tag(cfile)
|
||||
result.ap = taglib_file_audioproperties(cfile)
|
||||
|
||||
proc openTags*(path: Path): TagLibFile =
|
||||
## Open a file and return a TagLibFile object.
|
||||
let cfile = taglib_file_new(path.cstring)
|
||||
initTagLibFile(path, cfile)
|
||||
|
||||
proc openTags*(path: Path, fileType: TagLibFileType): TagLibFile =
|
||||
## Open a file of a specific type and return a TagLibFile object.
|
||||
let cfile = taglib_file_new_type(path.cstring, fileType)
|
||||
initTagLibFile(path, cfile)
|
||||
|
||||
proc writeTags*(file: TagLibFile) =
|
||||
## Write tags to the file.
|
||||
discard taglib_file_save(file.cfile)
|
||||
|
||||
proc close*(file: var TagLibFile) =
|
||||
## Close the file and free resources.
|
||||
if not file.cfile.isNil:
|
||||
taglib_tag_free_strings()
|
||||
taglib_file_free(file.cfile)
|
||||
file.cfile = nil
|
||||
file.tag = nil
|
||||
file.ap = nil
|
||||
|
||||
#proc `=destroy`*(file: TagLibFile) =
|
||||
# ## Destroy the file and free resources.
|
||||
# close(file)
|
||||
|
||||
{.push inline.}
|
||||
proc length*(file: TagLibFile): int = taglib_audioproperties_length(file.ap)
|
||||
proc bitrate*(file: TagLibFile): int = taglib_audioproperties_bitrate(file.ap)
|
||||
proc samplerate*(file: TagLibFile): int = taglib_audioproperties_samplerate(file.ap)
|
||||
proc channels*(file: TagLibFile): int = taglib_audioproperties_channels(file.ap)
|
||||
|
||||
proc title*(file: TagLibFile): string = $taglib_tag_title(file.tag)
|
||||
proc artist*(file: TagLibFile): string = $taglib_tag_artist(file.tag)
|
||||
proc album*(file: TagLibFile): string = $taglib_tag_album(file.tag)
|
||||
proc comment*(file: TagLibFile): string = $taglib_tag_comment(file.tag)
|
||||
proc genre*(file: TagLibFile): string = $taglib_tag_genre(file.tag)
|
||||
proc year*(file: TagLibFile): int = taglib_tag_year(file.tag).int
|
||||
proc track*(file: TagLibFile): int = taglib_tag_track(file.tag).int
|
||||
|
||||
proc `title=`*(file: var TagLibFile, title: string) = taglib_tag_set_title(file.tag, title.cstring)
|
||||
proc `artist=`*(file: var TagLibFile, artist: string) = taglib_tag_set_artist(file.tag, artist.cstring)
|
||||
proc `album=`*(file: var TagLibFile, album: string) = taglib_tag_set_album(file.tag, album.cstring)
|
||||
proc `comment=`*(file: var TagLibFile, comment: string) = taglib_tag_set_comment(file.tag, comment.cstring)
|
||||
proc `genre=`*(file: var TagLibFile, genre: string) = taglib_tag_set_genre(file.tag, genre.cstring)
|
||||
proc `year=`*(file: var TagLibFile, year: int) = taglib_tag_set_year(file.tag, year.cuint)
|
||||
proc `track=`*(file: var TagLibFile, track: int) = taglib_tag_set_track(file.tag, track.cuint)
|
||||
|
||||
proc albumArtist*(file: TagLibFile): string =
|
||||
## Get the album artist of the file.
|
||||
let albumArtist = taglib_property_get(file.cfile, "ALBUMARTIST")
|
||||
if albumArtist.isNil:
|
||||
return ""
|
||||
else:
|
||||
return $albumArtist
|
||||
|
||||
proc `albumArtist=`*(file: var TagLibFile, albumArtist: string) =
|
||||
## Set the album artist of the file.
|
||||
taglib_property_set(file.cfile, "ALBUMARTIST", albumArtist.cstring)
|
||||
|
||||
proc discNumber*(file: TagLibFile): int =
|
||||
## Get the disc number of the file.
|
||||
let discNumber = taglib_property_get(file.cfile, "DISCNUMBER")
|
||||
if discNumber.isNil:
|
||||
return 0
|
||||
else:
|
||||
return parseInt($discNumber)
|
||||
|
||||
proc `discNumber=`*(file: var TagLibFile, discNumber: int) =
|
||||
## Set the disc number of the file.
|
||||
taglib_property_set(file.cfile, "DISCNUMBER".cstring, ($discNumber).cstring)
|
||||
|
||||
# Other properties we could use:
|
||||
# - SUBTITLE
|
||||
# - DATE
|
||||
# - COMPOSER
|
||||
# - TITLESORT
|
||||
# - ARTISTSORT
|
||||
# - ALBUMSORT
|
||||
# - ALBUMARTISTSORT
|
||||
|
||||
{.pop.} # inline
|
111
src/main/nim/wdiwtlt/terminal.nim
Normal file
111
src/main/nim/wdiwtlt/terminal.nim
Normal file
@ -0,0 +1,111 @@
|
||||
import std/[nre, sequtils]
|
||||
|
||||
const CSI = "\x1b["
|
||||
const RESET_FORMATTING* = "\x1b[0m"
|
||||
const ANSI_ESCAPE_CODE_ENDINGS*: seq[char] = toSeq('A'..'Z') & toSeq('a'..'z')
|
||||
let FORMATTING_REGEX* = re("\x1b\\[([0-9;]*)([a-zA-Z])")
|
||||
|
||||
type
|
||||
CursorType* = enum
|
||||
ctBlockBlink = 1, ctBlock, ctUnderlineBlink, ctUnderline, ctBarBlink, ctBar
|
||||
|
||||
EraseMode* = enum
|
||||
emToEnd, emToStart, emAll
|
||||
|
||||
TerminalColors* = enum
|
||||
cBlack, cRed, cGreen, cYellow, cBlue, cMagenta, cCyan, cWhite
|
||||
|
||||
|
||||
proc stripFormatting*(text: string): string =
|
||||
text.replace(FORMATTING_REGEX, "")
|
||||
|
||||
|
||||
func ansiAwareSubstring*(s: string, start, length: int): string =
|
||||
result = ""
|
||||
var curAnsiEscCode = ""
|
||||
var i = 0
|
||||
var visibleLen = 0
|
||||
|
||||
while i < len(s) and visibleLen < length:
|
||||
|
||||
if len(s) > i + len(CSI) and
|
||||
# We need to notice ANSI escape codes...
|
||||
s[i..<i + len(CSI)] == CSI:
|
||||
var j = i + len(CSI)
|
||||
while j < s.len and s[j] notin ANSI_ESCAPE_CODE_ENDINGS: j += 1
|
||||
|
||||
# and remember it if we're before the start of the substring
|
||||
if i < start: curAnsiEscCode = s[i..j]
|
||||
|
||||
# or add it without increasing our substring length
|
||||
else: result.add(s[i..j])
|
||||
|
||||
# either way we want to pick up after it
|
||||
i = j
|
||||
|
||||
else:
|
||||
result.add(s[i])
|
||||
visibleLen += 1
|
||||
|
||||
i += 1
|
||||
|
||||
result = curAnsiEscCode & result
|
||||
|
||||
func color*(text: string, fg = cWhite, bg = cBlack): string =
|
||||
return CSI & $int(fg) & ";" & $(int(bg) + 40) & "m" & text & RESET_FORMATTING
|
||||
|
||||
|
||||
func color*(text: string, fg: TerminalColors): string =
|
||||
return CSI & $int(fg) & "m" & text & RESET_FORMATTING
|
||||
|
||||
|
||||
func color*(text: string, bg: TerminalColors): string =
|
||||
return CSI & $(int(bg) + 40) & "m" & text & RESET_FORMATTING
|
||||
|
||||
|
||||
func invert*(text: string): string = return CSI & "7m" & text & RESET_FORMATTING
|
||||
func striketrough*(text: string): string = return CSI & "9m" & text & RESET_FORMATTING
|
||||
|
||||
|
||||
func eraseDisplay*(mode = emToEnd): string = return CSI & $int(mode) & "J"
|
||||
func eraseLine*(mode = emToEnd): string = return CSI & $int(mode) & "K"
|
||||
|
||||
|
||||
func blinkSlow*(text: string): string = return CSI & "5m" & text & RESET_FORMATTING
|
||||
func blinkFast*(text: string): string = return CSI & "6m" & text & RESET_FORMATTING
|
||||
|
||||
|
||||
func cursorUp*(n: int = 1): string = return CSI & $int(n) & "A"
|
||||
func cursorDown*(n: int = 1): string = return CSI & $int(n) & "B"
|
||||
func cursorForward*(n: int = 1): string = return CSI & $int(n) & "C"
|
||||
func cursorBackward*(n: int = 1): string = return CSI & $int(n) & "D"
|
||||
func cursorNextLine*(n: int = 1): string = return CSI & $int(n) & "E"
|
||||
func cursorPrevLine*(n: int = 1): string = return CSI & $int(n) & "F"
|
||||
func cursorHorizontalAbsolute*(col: int): string = return CSI & $int(col) & "G"
|
||||
|
||||
|
||||
func cursorPosition*(row, col: int): string =
|
||||
return CSI & $int(row) & ";" & $int(col) & "H"
|
||||
|
||||
|
||||
proc cursorType*(ct: CursorType): string = return CSI & $int(ct) & " q"
|
||||
func saveCursorPosition*(): string = return CSI & "s"
|
||||
func restoreCursorPosition*(): string = return CSI & "u"
|
||||
|
||||
|
||||
func scrollUp*(n: int = 1): string = return CSI & $int(n) & "S"
|
||||
func scrollDown*(n: int = 1): string = return CSI & $int(n) & "T"
|
||||
|
||||
|
||||
proc write*(f: File, x, y: int, text: string) =
|
||||
f.write(cursorPosition(y, x) & text)
|
||||
|
||||
|
||||
proc write*(x, y: int, text: string) =
|
||||
stdout.write(cursorPosition(y, x) & text)
|
||||
|
||||
|
||||
proc setCursorType*(ct: CursorType) = stdout.write(cursorType(ct))
|
||||
proc setCursorPosition*(x, y: int) = stdout.write(cursorPosition(y, x))
|
||||
proc hideCursor*() = stdout.write(CSI & "?25l")
|
||||
proc showCursor*() = stdout.write(CSI & "?25l")
|
5
src/main/nim/wdiwtlt/usage.nim
Normal file
5
src/main/nim/wdiwtlt/usage.nim
Normal file
@ -0,0 +1,5 @@
|
||||
func usageOf*(command: seq[string]): string =
|
||||
case command[0]
|
||||
of "log":
|
||||
# TODO
|
||||
discard
|
7
src/main/nim/wdiwtlt/util.nim
Normal file
7
src/main/nim/wdiwtlt/util.nim
Normal file
@ -0,0 +1,7 @@
|
||||
func clamp*(value, min, max: int): int =
|
||||
if value < min:
|
||||
return min
|
||||
elif value > max:
|
||||
return max
|
||||
else:
|
||||
return value
|
112
src/main/sql/media-library-schema.sql
Normal file
112
src/main/sql/media-library-schema.sql
Normal file
@ -0,0 +1,112 @@
|
||||
CREATE TABLE artists (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX artists_name_idx ON artists(name);
|
||||
|
||||
CREATE TABLE albums (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
year INTEGER,
|
||||
track_total INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX albums_name_idx ON albums(name);
|
||||
|
||||
CREATE TABLE media_files (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
disc_number INTEGER NOT NULL DEFAULT 1,
|
||||
track_number INTEGER,
|
||||
play_count INTEGER NOT NULL DEFAULT 0,
|
||||
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', datetime())),
|
||||
last_played TEXT,
|
||||
present_locally INTEGER NOT NULL DEFAULT TRUE,
|
||||
comment TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX media_files_name_idx ON media_files(name);
|
||||
|
||||
CREATE TABLE artists_media_files (
|
||||
artist_id TEXT NOT NULL REFERENCES artists(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
media_file_id TEXT NOT NULL REFERENCES media_files(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
PRIMARY KEY (artist_id, media_file_id)
|
||||
);
|
||||
|
||||
CREATE TABLE albums_media_files (
|
||||
album_id TEXT NOT NULL REFERENCES albums(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
media_file_id TEXT NOT NULL REFERENCES media_files(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
PRIMARY KEY (album_id, media_file_id)
|
||||
);
|
||||
|
||||
CREATE TABLE tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE playlists (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_created INTEGER NOT NULL DEFAULT FALSE,
|
||||
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', datetime())),
|
||||
last_used TEXT NOT NULL DEFAULT (strftime('%F %TZ', datetime()))
|
||||
);
|
||||
|
||||
CREATE TABLE playlists_media_files (
|
||||
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
media_file_id TEXT NOT NULL REFERENCES media_files(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (playlist_id, media_file_id, position),
|
||||
UNIQUE (playlist_id, position)
|
||||
);
|
||||
|
||||
CREATE TABLE bookmarks (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
user_created INTEGER NOT NULL DEFAULT FALSE,
|
||||
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
media_file_id TEXT NOT NULL REFERENCES media_files(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
play_index INTEGER NOT NULL,
|
||||
play_time_ms INTEGER NOT NULL DEFAULT 0,
|
||||
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);
|
||||
CREATE INDEX bookmarks_media_file_id_idx ON bookmarks (media_file_id);
|
||||
|
||||
CREATE TABLE media_files_tags (
|
||||
media_file_id TEXT REFERENCES media_files(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
tag_id TEXT REFERENCES tags(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
PRIMARY KEY (media_file_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE images (
|
||||
id TEXT PRIMARY KEY,
|
||||
url TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE artists_images (
|
||||
artist_id TEXT REFERENCES artists (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
image_id TEXT REFERENCES images (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
PRIMARY KEY (artist_id, image_id)
|
||||
);
|
||||
|
||||
CREATE TABLE albums_images (
|
||||
album_id TEXT REFERENCES albums (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
image_id TEXT REFERENCES images (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
PRIMARY KEY (album_id, image_id)
|
||||
);
|
||||
|
||||
CREATE TABLE artists_albums (
|
||||
artist_id TEXT NOT NULL REFERENCES artists (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
album_id TEXT NOT NULL REFERENCES albums (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
PRIMARY KEY (artist_id, album_id)
|
||||
);
|
18
wdiwtlt.nimble
Normal file
18
wdiwtlt.nimble
Normal file
@ -0,0 +1,18 @@
|
||||
# Package
|
||||
|
||||
version = "0.2.0"
|
||||
author = "Jonathan Bernard"
|
||||
description = "What Do I Want To Listen To (WDIWTLT) - CLI media player."
|
||||
license = "GPL-3.0-or-later"
|
||||
srcDir = "src/main/nim"
|
||||
bin = @["wdiwtlt"]
|
||||
|
||||
|
||||
# Dependencies
|
||||
|
||||
requires "nim >= 2.2.0"
|
||||
requires @["checksums", "docopt", "illwill", "mpv", "nimterop", "taglib",
|
||||
"uuids", "waterpark"]
|
||||
|
||||
# Dependencies from https://git.jdb-software.com/jdb/nim-packages
|
||||
requires @["cliutils", "db_migrate", "fiber_orm", "namespaced_logging"]
|
21
worklog.md
21
worklog.md
@ -1,21 +0,0 @@
|
||||
WDIWTLT
|
||||
========================================
|
||||
|
||||
* Local library
|
||||
* CLI interface
|
||||
* REST API
|
||||
|
||||
|
||||
Current track
|
||||
----------------------------------------
|
||||
|
||||
Building CLI:
|
||||
* More clarity around selection
|
||||
management.
|
||||
* Tagging
|
||||
* Bookmark management
|
||||
* Playlist managment
|
||||
* Online command help.
|
||||
* Ability to actually play music.
|
||||
* Library management (split artists,
|
||||
change other metadata)?
|
Loading…
x
Reference in New Issue
Block a user