26 Commits

Author SHA1 Message Date
c75438d409 WIP: vi-like input system. 2025-03-15 07:19:30 -05:00
eadf3946e7 MD5 streaming implementation. 2025-03-04 15:51:02 -06:00
2d44d2c328 TagLib Nim wrapper. 2025-03-04 15:49:54 -06:00
3f3a6b286b Add DB implementation, models, logging. 2025-02-23 08:23:24 -06:00
bad288a24b Proof-of-concept MPV-based player. 2025-02-20 22:25:30 -06:00
bbd7952232 Initial WIP data models, fiber_orm mappings. 2025-02-20 22:25:10 -06:00
85f37373b6 Updated schema for Sqlite. 2025-02-20 22:24:49 -06:00
c3a5b5f87f Initial NIM stub (nimble file, gitignore. 2025-02-20 22:24:37 -06:00
bdd6c03129 Clean out old Groovy code. 2025-02-20 22:23:36 -06:00
a6371574a7 Allow multiple commands on one line using and or ;. 2017-01-16 04:22:22 -06:00
92d384573e Rescan the media library before reporting on absent files. 2016-09-08 14:18:20 -05:00
4468f606ed Bump H2 DB version. 2016-09-08 14:13:04 -05:00
4507c6b664 Added and commands to CLI. 2016-08-01 06:25:03 -05:00
ffcfc7bc77 Simplify the new playlist creation logic. 2016-06-01 11:31:05 -05:00
451956dc4f Stop the player before clearing the play queue. 2016-06-01 11:30:45 -05:00
a2c5c13ef4 Disable reporting of repeat mode until a working implementation can be found. 2016-06-01 11:30:02 -05:00
cbf01b4d73 Fix a bug where playlist media counts where being overwritten with stale data. 2016-06-01 11:29:39 -05:00
aa640bb4f9 Stop playBookmak from being updated when a song is paused. 2016-06-01 11:29:10 -05:00
7101f3fd53 Made playlists default to user-created. 2016-06-01 11:28:27 -05:00
19e21811e2 Switched to db-migrate.groovy configuration. 2016-06-01 11:26:53 -05:00
4007f8a479 Fix bug in help code. 2016-05-22 07:15:53 -05:00
981fb51af3 Added installation/build instructions. 2016-04-21 08:27:43 -05:00
bbf8a019f9 Added maven plugin to build. 2016-04-21 07:25:46 -05:00
816820c427 Use java system properties to find the user's home directory before looking to ENV properties. 2016-04-21 00:26:50 -05:00
7bd9c64c44 README title was ugly when displayed by gogs. 2016-04-20 12:46:31 -05:00
ed678872c9 Updated README for new version. 2016-04-20 12:45:28 -05:00
49 changed files with 1346 additions and 3715 deletions

5
.gitignore vendored
View File

@ -1,3 +1,4 @@
*.sw? *.sw?
.gradle/ /wdiwtlt
build/ nimble.develop
nimble.paths

View File

@ -1,21 +0,0 @@
# **W**hat **Do** **I** **W**ant **T**o **L**isten **T**o
## 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:
* Web-based interface.
* Mobile interface (could implement the Subsonic API on the back-end to be able
to use pre-made mobile apps)
* Song tagging.
* Playlists.
* Read meta-data from ID3.
* Transcoding on the fly.
* Read songs from Amazon S3.
* Stream from Amazon Cloudfront.
* Bookmarks (temporary song tag marking location in a playlist/album)
I have not found a music manager that gives me all of the above, so I'm going
to write my own.

View File

@ -1,22 +0,0 @@
allprojects {
buildscript {
repositories {
mavenLocal()
mavenCentral()
}
dependencies {
classpath 'ch.raffael.pegdown-doclet:pegdown-doclet:1.2'
}
}
group = 'com.jdbernard'
version = '0.1.0'
repositories {
mavenLocal()
mavenCentral()
maven { url "https://dl.bintray.com/ijabz/maven" }
}
}

View File

@ -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'
}

View File

@ -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

View File

@ -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) }
}

View File

@ -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() }
}

View File

@ -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) } }

View File

@ -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
View File

@ -0,0 +1,4 @@
# begin Nimble config (version 2)
when withDir(thisDir(), system.fileExists("nimble.paths")):
include "nimble.paths"
# end Nimble config

View File

@ -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

View File

@ -1,31 +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'
apply plugin: 'war'
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.185'
runtime 'org.postgresql:postgresql:9.4.1207.jre7'
}

View File

@ -1,5 +0,0 @@
{
"driver": "postgres",
"sqlDir": "src/main/sql/migrations",
"connectionString": "host=localhost port=5432 dbname=wdiwtlt user=jdbernard password="
}

View File

@ -1,84 +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(new File(env.WDIWTLT_CONFIG_FILE))) {
return } }
if (tryLoadConfigFile(new File(env.HOME, ".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(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 }
}

View File

@ -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

View File

@ -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; }
}

View File

@ -1,7 +0,0 @@
package com.jdbernard.wdiwtlt.db.models;
public class Artist extends Model {
public String name;
public String toString() { return name; }
}

View File

@ -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; }
}

View File

@ -1,7 +0,0 @@
package com.jdbernard.wdiwtlt.db.models;
public class Image extends Model {
public String url;
public String toString() { return url; }
}

View File

@ -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; }
}

View File

@ -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); }
}

View File

@ -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 = false;
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)"; }
}

View File

@ -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; }
}

View File

@ -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)
);

View File

@ -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)

View File

@ -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' }

View File

@ -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 {}

View File

@ -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"));
}
}

View File

@ -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"; } }

View File

@ -1,8 +0,0 @@
package com.jdbernard.nlsongs.servlet
import com.jdbernard.wdiwtlt.db.DbApi
public class WdiwtltContext {
public static DbApi dbapi
}

View File

@ -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') }
}

View File

@ -1,4 +0,0 @@
include 'core', 'cli', 'rest-server'
project(":core").name ="wdiwtlt-core"
project(":cli").name = "wdiwtlt-cli"

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

@ -0,0 +1,207 @@
import std/[json, options, os, paths, strutils]
import cliutils, docopt, illwill, mpv
import wdiwtlt/[cliconstants, commandbuffer, logging, medialibrary, models,
scrolltext]
type
CliMode {.pure.} = enum Direct, Command
ScrollingDisplays* = ref object
playing: ScrollText
status: ScrollText
History = ref object
entries: seq[string]
idx: int
CliContext = ref object
cfg: CombinedConfig
cmd: CommandBuffer
curMediaFile: Option[MediaFile]
frame: int
lib: MediaLibrary
log: Option[Logger]
mode: CliMode
mpv: ptr handle
stop*: bool
tb: TerminalBuffer
d: ScrollingDisplays
var ctx {.threadvar.}: CliContext
proc initMpv(): ptr handle =
result = mpv.create()
if result.isNil:
raise newException(ValueError, "failed creating mpv context")
# Enable default key bindings, so the user can actually interact with
# the player (and e.g. close the window).
result.set_option("terminal", "no")
result.set_option("input-default-bindings", "yes")
result.set_option("input-vo-keyboard", "yes")
result.set_option("osc", false)
#result.set_property("volume", "50.0")
discard result.request_log_messages("no")
# Done setting up options.
check_error result.initialize()
proc cleanup() =
terminate_destroy(ctx.mpv)
illWillDeinit()
showCursor()
echo ""
proc exitHandlerHook() {.noconv.} =
# This is called when the user presses Ctrl+C
# or when the program exits normally.
echo "Ctrl-C"
terminate_destroy(ctx.mpv)
illWillDeinit()
quit(QuitSuccess)
proc setMode(ctx: var CliContext, mode: CliMode) =
ctx.mode = mode
case mode
of CliMode.Direct:
hideCursor()
ctx.tb.clear()
of CliMode.Command:
showCursor()
ctx.cmd.mode = EditMode.Insert
proc initContext(args: Table[string, Value]): CliContext =
var wdiwtltCfgFilename: string
let log = getLogger("wdiwtlt")
let cfgLocations =
if args["--config"]: @[$args["--config"]]
elif existsEnv("XDG_CONFIG_HOME"): @[getEnv("XDG_CONFIG_HOME") / "wdiwtlt.json"]
else: @[$getEnv("HOME") / ".config" / "wdiwtlt.json" ]
try: wdiwtltCfgFilename = findConfigFile(".wdiwtlt.json", cfgLocations)
except ValueError:
log.error "could not find wdiwtlt config file: " & wdiwtltCfgFilename
if not args["--config"]:
wdiwtltCfgFilename = cfgLocations[0]
var cfgFile: File
try:
cfgFile = open(wdiwtltCfgFilename, fmWrite)
cfgFile.write((%*{
"libraryRoot":
if args["--library-root"]:
$args["--library-root"]
else:
getEnv("XDG_MUSIC_DIR", getEnv("HOME") / "music"),
"dbPath": getEnv("XDG_DATA_HOME", (getEnv("HOME") / ".local/share")) / "wdiwtlt/db.sqlite",
}).pretty)
log.info "created sample config file at " & wdiwtltCfgFilename
except CatchableError:
log.error "could not write default .wdiwtlt.json to " &
wdiwtltCfgFilename
finally: close(cfgFile)
quit(QuitFailure)
log.debug("loading config from '$#'" % [wdiwtltCfgFilename])
let cfg = initCombinedConfig(wdiwtltCfgFilename, args)
result = CliContext(
cfg: cfg,
cmd: initCommandBuffer(),
curMediaFile: none(MediaFile),
d: ScrollingDisplays(
playing: initScrollText("Nothing playing", terminalWidth() - 1),
status: initScrollText("Idle", terminalWidth() - 1)),
frame: 0,
lib: initMediaLibrary(
rootDir = getVal(cfg, "libraryRoot").Path,
dbPath = getVal(cfg, "dbPath").Path),
log: log,
mode: CliMode.Direct,
mpv: initMpv(),
stop: false,
tb: newTerminalBuffer(terminalWidth(), terminalHeight()))
if logService.isSome:
let customLogAppender = initCustomLogAppender(doLogMessage =
proc (msg: LogMessage): void =
ctx.tb.write(0, 0, msg.message))
logService.get.clearAppenders()
logService.get.addAppender(customLogAppender)
proc render(ctx: CliContext) =
if ctx.frame mod 20 == 0: discard ctx.d.playing.nextTick
ctx.tb.write(0, 0, ctx.d.playing)
if ctx.mode == CliMode.Command:
case ctx.cmd.mode:
of EditMode.Insert: stdout.write("\x1b[5 q")
of EditMode.Overwrite: stdout.write("\x1b[0 q")
else:
stdout.write("\x1b[2 q")
ctx.tb.write(0, 1, ":$#" % [$ctx.cmd])
ctx.tb.display()
proc mainLoop(ctx: var CliContext) =
hideCursor()
while not ctx.stop:
let key = getKey()
if ctx.mode == CliMode.Direct:
case key
of Key.Q: ctx.stop = true
of Key.Colon, Key.I: ctx.setMode(CliMode.Command)
else: discard
elif ctx.mode == CliMode.Command:
ctx.tb.write(0, 1, ":", ' '.repeat(($ctx.cmd).len))
case key
of Key.Enter:
let command = $ctx.cmd
ctx.cmd.clear
# TODO: process command
of Key.Backspace: ctx.cmd.handleInput(Key.Backspace)
of Key.Escape:
if ctx.cmd.mode == EditMode.Command:
ctx.setMode(CliMode.Direct)
ctx.cmd.clear
else: ctx.cmd.handleInput(Key.Escape)
else: ctx.cmd.handleInput(key)
render(ctx)
# target 50 FPS
sleep(20)
ctx.frame = (ctx.frame + 1 mod 50)
when isMainModule:
discard enableLogging()
try:
let args = docopt(USAGE, version=VERSION)
illWillInit(fullscreen=true)
ctx = initContext(args)
mainLoop(ctx)
except Exception:
let ex = getCurrentException()
getLogger("wdiwtlt").error(%*{
"msg": "Unhandled exception",
"error": ex.msg,
"trace": ex.getStackTrace()
})
cleanup()
quit(QuitFailure)
finally:
cleanup()

View File

@ -0,0 +1,7 @@
import std/nre
const CSI* = "\x1b["
const ANSI_REGEX_PATTERN* = "\x1b\\[([0-9;]*)([a-zA-Z])"
func stripAnsi*(text: string): string =
text.replace(re(ANSI_REGEX_PATTERN), "")

View 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
"""

View File

@ -0,0 +1,161 @@
import std/[options]
import illwill
type
EditMode* {.pure.} = enum Command, Insert, Overwrite, Visual
Command = ref object
buffer: string
idx: int
selectionStartIdx: Option[int]
CommandBuffer* = ref object
history: seq[Command]
idx: int
mode*: EditMode
func clamp(value, min, max: int): int =
if value < min:
return min
elif value > max:
return max
else:
return value
func initCommandBuffer*(): CommandBuffer =
result = CommandBuffer(
history: @[Command(
buffer: "",
idx: 0,
selectionStartIdx: none(int))],
idx: 0,
mode: Insert)
proc cur(cb: CommandBuffer): Command = cb.history[cb.idx]
proc cur(cb: var CommandBuffer): Command = cb.history[cb.idx]
proc prev*(cb: var CommandBuffer) =
cb.idx = clamp( cb.idx - 1, 0, cb.history.len - 1)
proc next*(cb: var CommandBuffer) =
cb.idx = clamp( cb.idx + 1, 0, cb.history.len - 1)
proc insert*(cb: var CommandBuffer, s: string) =
var cmd = cb.history[cb.idx]
cmd.buffer = cmd.buffer[0..<cmd.idx] & s & cmd.buffer[cmd.idx..^1]
cmd.idx += s.len
proc overwrite*(cb: var CommandBuffer, s: string) =
var cmd = cb.history[cb.idx]
if cmd.idx + s.len > cmd.buffer.len:
cmd.buffer = cmd.buffer[0..<cmd.idx] & s
else:
cmd.buffer = cmd.buffer[0..<cmd.idx] & s & cmd.buffer[cmd.idx + s.len..^1]
cmd.idx += s.len
proc write*(cb: var CommandBuffer, s: string) =
if cb.mode == EditMode.Insert: cb.insert(s)
elif cb.mode == EditMode.Overwrite: cb.overwrite(s)
proc delete*(cb: var CommandBuffer, backspace = true) =
var cmd = cb.history[cb.idx]
if cmd.idx > 0:
if backspace:
cmd.buffer = cmd.buffer[0..<cmd.idx - 1] & cmd.buffer[cmd.idx..^1]
else:
cmd.buffer = cmd.buffer[0..<cmd.idx] & cmd.buffer[cmd.idx + 1..^1]
cmd.idx -= 1
proc clear*(cb: var CommandBuffer) =
cb.history.add(Command(
buffer: "",
idx: 0,
selectionStartIdx: none(int)))
cb.idx = cb.history.len - 1
proc left*(cb: var CommandBuffer) =
cb.cur.idx = clamp(cb.cur.idx - 1, 0, cb.cur.buffer.len)
proc right*(cb: var CommandBuffer) =
cb.cur.idx = clamp(cb.cur.idx + 1, 0, cb.cur.buffer.len)
proc toHome*(cb: var CommandBuffer) = cb.cur.idx = 0
proc toEnd*(cb: var CommandBuffer) = cb.cur.idx = cb.cur.buffer.len
proc backWord*(cb: var CommandBuffer) =
var cmd = cb.history[cb.idx]
while cmd.idx > 0 and cmd.buffer[cmd.idx - 1] == ' ': cmd.idx -= 1
while cmd.idx > 0 and cmd.buffer[cmd.idx - 1] != ' ': cmd.idx -= 1
proc forwardWord*(cb: var CommandBuffer) =
var cmd = cb.history[cb.idx]
while cmd.idx < cmd.buffer.len and cmd.buffer[cmd.idx] == ' ':
cmd.idx += 1
while cmd.idx < cmd.buffer.len and cmd.buffer[cmd.idx] != ' ':
cmd.idx += 1
func toChar(k: Key): char = cast[char](ord(k))
proc handleInput*(cb: var CommandBuffer, key: Key) =
case cb.mode
of EditMode.Insert, EditMode.Overwrite:
case key
of Key.Escape: cb.mode = EditMode.Command
of Key.Backspace: cb.delete()
of Key.Delete: cb.delete(backspace = false)
of Key.Left: cb.left()
of Key.Right: cb.right()
of Key.Up: cb.prev()
of Key.Down: cb.next()
of Key.Home: cb.toHome()
of Key.End: cb.toEnd()
of Key.CtrlH: cb.backWord()
of Key.CtrlL: cb.forwardWord()
elif key >= Key.Space and key <= Key.Tilde: cb.write($toChar(key))
else: discard
of EditMode.Command:
case key
of Key.Backspace: cb.delete()
of Key.Delete, Key.X: cb.delete(backspace = false)
of Key.Left, Key.H: cb.left()
of Key.Right, Key.L: cb.right()
of Key.Up, Key.J: cb.prev()
of Key.Down, Key.K: cb.next()
of Key.Home, Key.Zero: cb.toHome()
of Key.End, Key.Dollar: cb.toEnd()
of Key.B: cb.backWord()
of Key.W: cb.forwardWord()
of Key.I: cb.mode = EditMode.Insert
of Key.ShiftR: cb.mode = EditMode.Overwrite
of Key.V:
cb.mode = EditMode.Visual
cb.cur.selectionStartIdx = some(cb.cur.idx)
else: discard
of EditMode.Visual:
case key
of Key.Escape:
cb.mode = EditMode.Command
cb.cur.selectionStartIdx = none(int)
of Key.Backspace: cb.left()
of Key.Left, Key.H: cb.left()
of Key.Right, Key.L: cb.right()
of Key.Up, Key.J:
cb.cur.selectionStartIdx = none[int]()
cb.prev()
of Key.Down, Key.K:
cb.cur.selectionStartIdx = none[int]()
cb.next()
of Key.Home, Key.Zero: cb.toHome()
of Key.End, Key.Dollar: cb.toEnd()
of Key.B: cb.backWord()
of Key.W: cb.forwardWord()
of Key.V:
cb.mode = EditMode.Command
cb.cur.selectionStartIdx = none(int)
else: discard
func `$`*(cb: CommandBuffer): string = cb.cur.buffer

213
src/main/nim/wdiwtlt/db.nim Normal file
View 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))

View 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)

View 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]()

View 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

View 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

View File

@ -0,0 +1,48 @@
type ScrollText* = ref object
text: string
maxWidth: int
scrollIdx: int # Current index of the first character to show.
lastRender: string
proc render(st: ScrollText): string =
if st.text.len <= st.maxWidth:
return st.text
if st.scrollIdx == 0: return st.text[0..<st.maxWidth]
elif st.scrollIdx == st.text.len:
return " " & st.text[0..<st.maxWidth - 1]
elif st.scrollIdx + st.maxWidth < st.text.len:
return st.text[st.scrollIdx..<(st.scrollIdx + st.maxWidth)]
else:
return st.text[st.scrollIdx..<st.text.len] & " " &
st.text[0..<max(0, (st.scrollIdx + st.maxWidth - st.text.len - 1))]
proc `text=`*(st: var ScrollText, text: string) =
st.text = text
st.scrollIdx = max(0, text.len - 10)
st.lastRender = render(st)
proc `maxWidth=`*(st: var ScrollText, maxWidth: int) =
st.maxWidth = max(maxWidth, 1)
st.lastRender = render(st)
proc initScrollText*(text: string, maxWidth: int): ScrollText =
result = ScrollText(
text: text,
maxWidth: maxWidth,
scrollIdx: 0)
result.lastRender = render(result)
proc nextTick*(st: var ScrollText): string =
st.lastRender = render(st)
# Advance the scroll index by one.
st.scrollIdx = (st.scrollIdx + 1) mod (st.text.len + 1)
return st.lastRender
func `$`*(st: ScrollText): string = st.lastRender
converter toString*(st: ScrollText): string = st.lastRender

View 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

View 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
View 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"]

View File

@ -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)?