Renamed module service->core.

This commit is contained in:
Jonathan Bernard
2016-02-09 15:32:59 -06:00
parent cf82525832
commit a4d54cb05c
16 changed files with 0 additions and 0 deletions

10
core/README.md Normal file
View File

@ -0,0 +1,10 @@
## 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

30
core/build.gradle Normal file
View File

@ -0,0 +1,30 @@
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'
testCompile 'junit:junit:4.12'
runtime 'com.h2database:h2:1.4.185'
runtime 'org.postgresql:postgresql:9.4.1207.jre7'
}

5
core/database.json Normal file
View File

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

View File

@ -0,0 +1,147 @@
package com.jdbernard.wdiwtlt
import com.jdbernard.wdiwtlt.db.ORM
import com.jdbernard.wdiwtlt.db.models.*
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 org.apache.commons.codec.digest.DigestUtils
import static org.jaudiotagger.tag.FieldKey.*
public class MediaLibrary {
private static Logger logger = LoggerFactory.getLogger(MediaLibrary)
private ORM orm
private File libraryRoot
public MediaLibrary(ORM orm, File rootDir) {
logger.debug("Creating a MediaLibrary rooted at: {}",
rootDir.canonicalPath)
this.orm = orm
this.libraryRoot = rootDir }
public void clean() {
orm.removeEmptyAlbums()
orm.removeEmptyArtists()
orm.removeEmptyPlaylists()
}
public void rescanLibrary() {
libraryRoot.eachFileRecurse { addFile(it) }
}
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 mf = orm.getMediaFileByFilePath(relPath)
if (mf) {
logger.info(
"Ignoring a media file I already know about: {}", relPath)
return mf }
// Read in the media's tags
mf = new MediaFile()
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{}",
e.localizedMessage)
return null }
def fileTag = af.tag
mf.name = fileTag?.getFirst(TITLE)?.trim() ?: f.name
mf.filePath = relPath
mf.comment = fileTag?.getFirst(COMMENT)?.trim()
mf.trackNumber = (fileTag?.getFirst(TRACK) ?: null) as Integer
def folderParts = mf.filePath.split("[\\\\/]")[1..<-1] as LinkedList
// Find artist and album names (if any)
def artistName = fileTag?.getFirst(ARTIST)?.trim()
def albumName = fileTag?.getFirst(ALBUM)?.trim()
if (!artistName) {
mf.metaInfoSource = MediaFile.FILE_LOCATION
artistName = folderParts.size() >= 2 ? folderParts[0] : null }
if (!albumName) {
mf.metaInfoSource = MediaFile.FILE_LOCATION
albumName = folderParts.peekLast() }
// Hash the file
mf.fileHash = f.withInputStream { DigestUtils.md5Hex(it) }
orm.withTransaction {
orm.create(mf)
associateWithArtistAndAlbum(mf, artistName, albumName, fileTag)
}
}
private void associateWithArtistAndAlbum(MediaFile mf, String artistName,
String albumName, JATag fileTag) {
Artist artist = null
Album album = null
boolean newAlbumOrArtist = false
if (artistName) {
artist = orm.getArtistByName(artistName)
if (!artist) {
newAlbumOrArtist = true
artist = new Artist(name: artistName)
orm.create(artist) } }
if (albumName) {
album = orm.getAlbumByName(albumName)
if (!album) {
newAlbumOrArtist = true
album = new Album(name: albumName,
year: (fileTag?.getFirst(YEAR) ?: null) as Integer,
trackTotal: (fileTag?.getFirst(TRACK_TOTAL) ?: null) as Integer)
orm.create(album) } }
if (artist && album && newAlbumOrArtist)
orm.addAlbumArtist(album.id, artist.id)
if (artist) orm.associateMediaFileWithArtist(mf.id, artist.id)
if (album) orm.associateMediaFileWithAlbum(mf.id, album.id)
}
/** #### `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('/') }
}

View File

@ -0,0 +1,285 @@
package com.jdbernard.wdiwtlt.db
import java.lang.reflect.Modifier
import java.sql.Connection
import java.sql.PreparedStatement
import java.sql.ResultSet
import java.text.SimpleDateFormat
import java.util.regex.Pattern
import javax.sql.DataSource
import groovy.sql.Sql
import groovy.transform.CompileStatic
import groovy.transform.TailRecursive
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import com.jdbernard.wdiwtlt.db.models.*
public class ORM {
private DataSource dataSource
private Sql sql
private static UPPERCASE_PATTERN = Pattern.compile(/(.)(\p{javaUpperCase})/)
private static Logger logger = LoggerFactory.getLogger(ORM)
public ORM(DataSource dataSource) {
this.dataSource = dataSource
this.sql = new Sql(dataSource) }
public void shutdown() { dataSource.shutdown() }
/// ### Common
public def getById(int id, Class modelClass) {
def query = new StringBuilder()
.append("SELECT * FROM ")
.append(pluralize(nameFromModel(modelClass.simpleName)))
.append(" WHERE id = ?")
.toString()
logger.debug("Selecting model.\n\tSQL: {}\n\tPARAMS: {}", query, id)
return recordToModel(sql.firstRow(query, [id]), modelClass) }
public def getByName(String name, Class modelClass) {
def query = new StringBuilder()
.append("SELECT * FROM ")
.append(pluralize(nameFromModel(modelClass.simpleName)))
.append(" WHERE name = ?")
.toString()
logger.debug("Selecting model.\n\tSQL: {}\n\tPARAMS: {}", query, name)
return recordToModel(sql.firstRow(query, [name]), modelClass) }
public def getBy(List<String> columns, List<Object> values,
Class modelClass) {
def query = new StringBuilder()
.append("SELECT * FROM ")
.append(pluralize(nameFromModel(modelClass.simpleName)))
.append(" WHERE ")
.append(columns.collect { it + " = ?" }.join(' AND '))
.toString()
logger.debug("Selecting models.\n\tSQL: {}\n\tPARAMS: {}", query, values)
return sql.rows(query, values)
.collect { recordToModel(it, modelClass) } }
public def save(def model) {
if (model.id > 0) return update(model)
else return create(model) }
public def update(def model) {
def setClauses = []
def params = []
getInstanceFields(model.class)
.findAll { it.name != 'id' }
.each { field ->
setClauses << '"' + nameFromModel(field.name) + '"= ?'
params << field.get(model) }
def query = new StringBuilder()
.append("UPDATE ")
.append(pluralize(nameFromModel(model.class.simpleName)))
.append(" SET ")
.append(setClauses.join(', '))
.append(" WHERE id = ")
.append(model.id)
.toString()
logger.debug("Updating model.\n\tSQL: {}\n\tPARAMS: {}", query, params)
return sql.executeUpdate(query, params) }
public def create(def model) {
def columns = []
def params = []
getInstanceFields(model.class)
.findAll { it.name != 'id' }
.each { field ->
//if (field.class.getAnnotation(Model)) // check to see if we
// have nested models
columns << '"' + nameFromModel(field.name) + '"'
params << field.get(model) }
def query= new StringBuilder()
.append("INSERT INTO ")
.append(pluralize(nameFromModel(model.class.simpleName)))
.append(" (")
.append(columns.join(', '))
.append(") VALUES (")
.append((1..columns.size()).collect { '?' }.join(', '))
.append(")").toString()
logger.debug("Creating model.\n\tSQL: {}\n\tPARAMS: {}", query, params)
model.id = sql.executeInsert(query, params)[0][0]
return 1 }
public def delete(def model) {
def query = new StringBuilder()
.append("DELETE FROM ")
.append(pluralize(nameFromModel(model.class.simpleName)))
.append("WHERE id = ?")
.toString()
logger.debug("Deleting model.\n\tSQL: {}\n\tPARAMS: {}", query, model.id)
sql.execute(query, [model.id])
return sql.updateCount }
public def associate(String linkTable, Integer firstId, Integer secondId) {
def query = "INSERT INTO $linkTable VALUES (?, ?) ON CONFLICT DO NOTHING"
def params = [firstId, secondId]
logger.debug("Creating association.\n\tSQL: {}\n\tPARAMS: {}",
query, params)
return sql.execute(query, params) }
public def associate(def m1, def m2) {
return associate(pluralize(nameFromModel(m1.class.simpleName)) +
"_" + pluralize(nameFromModel(m2.class.simpleName)),
m1.id, m2.id) }
/// ### Album-specific methods
public Album getAlbumById(int id) { return getById(id, Album) }
public Album getAlbumByName(String name) { return getByName(name, Album) }
public Album getAlbumByNameAndArtistId(String name, int artistId) {
def query = """\
SELECT al.*
FROM albums al JOIN
artists_albums aa ON
al.id = aa.album_id AND
aa.artist_id = ?
WHERE al.name = ?"""
def params = [artistId, name]
logger.debug("Selecting albums.\n\tSQL: {}\n\tPARAMS: {}",
query, params)
def albums = sql.rows(query, params)
.collect { recordToModel(it, Album) }
return albums ? albums[0] : null }
public Album getAlbumsByArtistId(int artistId) {
def query = """\
SELECT al.*
FROM albums al JOIN
artists_albums aa ON
al.id = aa.album_id AND
aa.artist_id = ?"""
logger.debug("Selecting albums.\n\tSQL: {}\n\tPARAMS: {}", query, artistId)
return sql.rows(query, [artistId])
.collect { recordToModel(it, Album) } }
public List<Album> removeEmptyAlbums() {
throw new UnsupportedOperationException("Not yet implemented.");
}
/// ### Artist-specific methods
public Artist getArtistById(int id) { return getById(id, Artist) }
public Artist getArtistByName(String name) { return getByName(name, Artist) }
public Artist getArtistsByAlbum(int albumId) {
var query = """\
SELECT ar.*
FROM artists ar JOIN
artists_albums aa ON
ar.id = aa.artist_id AND
aa.album_id = ?"""
logger.debug("Selecting artists.\n\tSQL: {}\n\tPARAMS: {}", query, artistId)
return sql.rows(query, [albumId])
.collect { recordToModel(it, Artist) } }
public List<Artist> removeEmptyArtists() {
throw new UnsupportedOperationException("Not yet implemented.");
}
public def addAlbumArtist(int albumId, int artistId) {
return associate("artists_albums", artistId, albumId) }
/// ### Bookmark-specific methods
public Bookmark getBookmarkById(int id) { return getById(id, Bookmark) }
public Bookmark getBookmarkByName(String name) { return getByName(name, Bookmark) }
/// ### Image-specific methods
public Image getImageById(int id) { return getById(id, Image) }
/// ### MediaFile-specific methods
public MediaFile getMediaFileById(int id) { return getById(id, MediaFile) }
public MediaFile getMediaFileByName(String name) { return getByName(name, MediaFile) }
public MediaFile getMediaFileByFilePath(String filePath) {
def files = getBy(["file_path"], [filePath], MediaFile)
return files ? files[0] : null }
public def associateMediaFileWithAlbum(int mediaFileId, int albumId) {
return associate("albums_media_files", albumId, mediaFileId) }
public def associateMediaFileWithArtist(int mediaFileId, int artistId) {
return associate("artists_media_files", artistId, mediaFileId) }
public def incrementPlayCount(int mediaFileId) {
def query = "UPDATE media_files SET play_count = play_count + 1 WHERE ID = ?"
def params = [mediaFileId]
logger.debug("Updating media file.\n\tSQL: {}\n\tPARAMS: {}", query, params)
sql.executeUpdate(query, params)
query = "SELECT play_count FROM media_files WHERE id = ?"
logger.debug("Selecting media file play count.\n\tSQL: {}\n\tPARAMS: {}", query, params)
return sql.firstRow(query, params)[0] }
public MediaFile incrementPlayCount(MediaFile mf) {
mf.playCount = incrementPlayCount(mf.id)
return mf }
/// ### Playlist-specific methods
public Playlist getPlaylistById(int id) { return getById(id, Playlist) }
public Playlist getPlaylistByName(String name) { return getByName(name, Playlist) }
public List<Playlist> removeEmptyPlaylists() {
throw new UnsupportedOperationException("Not yet implemented.");
}
/// ### Tag-specific methods
public Tag getTagById(int id) { return getById(id, Tag) }
public Tag getTagByName(String name) { return getByName(name, Tag) }
/// ### Utility functions
public void withTransaction(Closure c) { sql.withTransaction(c) }
public static String nameToModel(String name) {
def pts = name.toLowerCase().split('_')
return pts.length == 1 ? pts[0] :
pts[0] + pts[1..-1].collect { it.capitalize() }.join() }
public static String nameFromModel(String name) {
return UPPERCASE_PATTERN.matcher(name).
replaceAll(/$1_$2/).toLowerCase() }
public static String pluralize(String name) { return name + "s" }
static def updateModel(def record, def model) {
getInstanceFields(model.class).each { field ->
field.set(model, record[nameFromModel(field.name)]) }
return model }
static def recordToModel(def record, Class clazz) {
if (record == null) return null
def model = clazz.newInstance()
getInstanceFields(model.class).each { field ->
field.set(model, record[nameFromModel(field.name)]) }
return model }
static def modelToRecord(def model) {
if (model == null) return null
def record = [:]
getInstanceFields(model.class).each { field ->
record[nameFromModel(field.name)] = field.get(model) }
return record }
static def getInstanceFields(Class clazz) {
return clazz.fields.findAll { !Modifier.isStatic(it.modifiers) } }
}

View File

@ -0,0 +1,13 @@
package com.jdbernard.wdiwtlt.db.models;
@Model
public class Album {
public int id;
public String name;
public Integer trackTotal;
public Integer year;
public String toString() {
if (year != null) return name + " (" + year + ")";
else return name; }
}

View File

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

View File

@ -0,0 +1,12 @@
package com.jdbernard.wdiwtlt.db.models;
@Model
public class Bookmark {
public int id;
public String name;
public int playlistId;
public int mediaFileId;
public int playIndex;
public String toString() { return name; }
}

View File

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

View File

@ -0,0 +1,18 @@
package com.jdbernard.wdiwtlt.db.models;
@Model
public class MediaFile {
public static final String TAG_INFO = "tag info";
public static final String FILE_LOCATION = "file location";
public int id;
public String name;
public Integer trackNumber;
public int playCount = 0;
public String filePath;
public String fileHash;
public String metaInfoSource = TAG_INFO;
public String comment;
public String toString() { return name; }
}

View File

@ -0,0 +1,7 @@
package com.jdbernard.wdiwtlt.db.models;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
@interface Model { }

View File

@ -0,0 +1,10 @@
package com.jdbernard.wdiwtlt.db.models;
@Model
public class Playlist {
public int id;
public String name;
public int modCount;
public String toString() { return name; }
}

View File

@ -0,0 +1,9 @@
package com.jdbernard.wdiwtlt.db.models;
@Model
public class Tag {
public int id;
public String name;
public String toString() { return name; }
}

View File

@ -0,0 +1,14 @@
DROP TABLE artists_albums;
DROP TABLE albums_images;
DROP TABLE artists_images;
DROP TABLE images;
DROP TABLE media_files_tags;
DROP TABLE bookmarks;
DROP TABLE playlists_media_files;
DROP TABLE playlists;
DROP TABLE tags;
DROP TABLE artists_media_files;
DROP TABLE albums_media_files;
DROP TABLE media_files;
DROP TABLE albums;
DROP TABLE artists;

View File

@ -0,0 +1,95 @@
CREATE TABLE artists (
id SERIAL PRIMARY KEY,
name VARCHAR UNIQUE NOT NULL
);
CREATE INDEX artists_name_idx ON artists(name);
CREATE TABLE albums (
id SERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
year INTEGER,
track_total INTEGER
);
CREATE INDEX albums_name_idx ON albums(name);
CREATE TABLE media_files (
id SERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
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'
comment VARCHAR DEFAULT ''
);
CREATE INDEX media_files_name_idx ON media_files(name);
CREATE TABLE artists_media_files (
artist_id INTEGER NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
media_file_id INTEGER NOT NULL REFERENCES media_files(id) ON DELETE CASCADE,
PRIMARY KEY (artist_id, media_file_id)
);
CREATE TABLE albums_media_files (
album_id INTEGER NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
media_file_id INTEGER NOT NULL REFERENCES media_files(id) ON DELETE CASCADE,
PRIMARY KEY (album_id, media_file_id)
);
CREATE TABLE tags (
id SERIAL PRIMARY KEY,
name VARCHAR UNIQUE NOT NULL
);
CREATE TABLE playlists (
id SERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
mod_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE playlists_media_files (
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
media_file_id INTEGER NOT NULL REFERENCES media_files(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
UNIQUE (playlist_id, media_file_id)
);
CREATE TABLE bookmarks (
id SERIAL PRIMARY KEY,
name VARCHAR,
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
media_file_id INTEGER NOT NULL REFERENCES media_files(id) ON DELETE CASCADE,
play_index INTEGER NOT NULL
);
CREATE TABLE media_files_tags (
id SERIAL PRIMARY KEY,
media_file_id INTEGER REFERENCES media_files(id) ON DELETE CASCADE,
tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE
);
CREATE TABLE images (
id SERIAL PRIMARY KEY,
url VARCHAR
);
CREATE TABLE artists_images (
artist_id INTEGER REFERENCES artists (id) ON DELETE CASCADE,
image_id INTEGER REFERENCES images (id) ON DELETE CASCADE,
PRIMARY KEY (artist_id, image_id)
);
CREATE TABLE albums_images (
album_id INTEGER REFERENCES albums (id) ON DELETE CASCADE,
image_id INTEGER REFERENCES images (id) ON DELETE CASCADE,
PRIMARY KEY (album_id, image_id)
);
CREATE TABLE artists_albums (
artist_id INTEGER NOT NULL REFERENCES artists (id) ON DELETE CASCADE,
album_id INTEGER NOT NULL REFERENCES albums (id) ON DELETE CASCADE,
PRIMARY KEY (artist_id, album_id)
);

35
core/test.groovy Normal file
View File

@ -0,0 +1,35 @@
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)