#!/usr/bin/env python3.5

# --- DATABASE SCHEMA: ---
#   SRC_PATH	TEXT	NOT NULL - Directory path of source file
# , SRC_NAME	TEXT	NOT NULL - Filename of source file. SRC_PATH + SRC_NAME gets the full path
# , SRC_HASH	TEXT		 - SHA256 of the source file, used to detect modifications
# , SRC_DMMOD	REAL		 - Modified date of the source file, used to detect modifications
# , DEST_PATH	TEXT	NOT NULL - Directory path of encoded/copied file
# , DEST_NAME	TEXT	NOT NULL - Filename of encoded/copied file. Equals SRC_NAME if file is only copied
# , ACTION	INT	NOT NULL - Action to perform on File. Set to 3 at the end of run by default, then changed during
#				   initialize_db(), assuming that the file still exists. Values:
#					0 - OK (Do nothing)
#					1 - ENCODE
#					2 - COPY
#					3 - DELETE (default, cleared by initialize_db())

import sqlite3, argparse, os, subprocess, multiprocessing
from sys import version_info
from hashlib import sha256
from glob import glob
from shutil import copyfile
from mimetypes import guess_type

def check_requirements():
	assert version_info >= (3,5)
	devNull = open(os.devnull, 'w')

	try: subprocess.call(["ffmpeg"], stdout = devNull, stderr = devNull)
	except OSError as e:
		print("ERROR: ffmpeg could not be found. Please make sure that you have ffmpeg installed")
		raise
	try: subprocess.call(["sqlite3", "--version"], stdout = devNull)
	except OSError as e:
		print("ERROR: sqlite3 could not be found. Please make sure that you have sqlite3 installed")
		raise


def initialize_db(srcPath, destPath, fileType, mirrorMode):
	#Scans through fs entries and compares them against the ones in db. Then adds, removes or updates the entires in the db according to the fs
	con = sqlite3.connect('files.db')
	with con:
		cur = con.cursor()
		cur.execute("CREATE TABLE IF NOT EXISTS FILES(SRC_PATH TEXT NOT NULL, SRC_NAME TEXT NOT NULL, SRC_HASH TEXT, DEST_PATH TEXT NOT NULL, DEST_NAME TEXT NOT NULL, ACTION INT NOT NULL)")
		cur.execute("UPDATE FILES SET ACTION = 3") #Set all to delete. Any files that are on the fs will get a different flag in the next step
	for currentEntry in glob('%s**' % srcPath, recursive=True):
		try: currentSrcHash = sha256(open(currentEntry, 'rb').read()).hexdigest()
		except OSError as e:
			if e.errno == os.errno.EISDIR: continue  #Hash throws an error when it tries to hash dirs, we use this to exclude any dirs from the db
		currentMimeType = guess_type(currentEntry)
		currentSrcPath = os.path.dirname(currentEntry) + '/'
		currentSrcName = os.path.basename(currentEntry)
		with con:
			cur = con.cursor()
			cur.execute("SELECT SRC_HASH FROM FILES WHERE SRC_PATH = ? AND SRC_NAME = ?", (currentSrcPath, currentSrcName))
			dbCheckEntry = cur.fetchone()
			if dbCheckEntry is None and currentMimeType[0] == 'audio/flac':
				currentDestPath = currentSrcPath.replace(srcPath, destPath)
				currentDestName = currentSrcName.replace('.flac', fileType)
				cur.execute("INSERT INTO FILES VALUES(?,?,?,?,?,1)", (currentSrcPath, currentSrcName, currentSrcHash, currentDestPath, currentDestName))
			elif dbCheckEntry is None and currentMimeType[0] != 'audio/flac' and mirrorMode != 'flac-only':
				currentDestPath = currentSrcPath.replace(srcPath, destPath)
				currentDestName = currentSrcName
				cur.execute("INSERT INTO FILES VALUES(?,?,?,?,?,2)", (currentSrcPath, currentSrcName, currentSrcHash, currentDestPath, currentDestName))
			elif dbCheckEntry is None and currentMimeType[0] != 'audio/flac' and mirrorMode == 'flac-only':
				continue #Dont add normal files to db when in flac-only
			elif dbCheckEntry[0] != currentSrcHash and currentMimeType[0] == 'audio/flac':
				cur.execute("UPDATE FILES SET SRC_HASH = ?, ACTION = 1 WHERE SRC_PATH = ? AND SRC_NAME = ?", (currentSrcHash, currentSrcPath, currentSrcName))
			elif dbCheckEntry[0] != currentSrcHash and currentMimeType[0] != 'audio/flac':
				cur.execute("UPDATE FILES SET SRC_HASH = ?, ACTION = 2 WHERE SRC_PATH = ? AND SRC_NAME = ?", (currentSrcHash, currentSrcPath, currentSrcName))
			elif dbCheckEntry[0] == currentSrcHash:
				cur.execute("UPDATE FILES SET ACTION = 0 WHERE SRC_PATH = ? AND SRC_NAME = ?", (currentSrcPath, currentSrcName))
			#All files on fs now have a different state than ACTION = 3 (delete), whereas all files in the db but not on the fs are being deleted.
	return

def mirror_directories():
	#Copies directories and files that don't have to be encoded.
	con = sqlite3.connect('files.db')
	with con:
		cur = con.cursor()
		cur.execute("SELECT SRC_PATH, SRC_NAME, DEST_PATH, DEST_NAME, ACTION FROM FILES WHERE ACTION IN (1, 2)")
		dbWriteList = cur.fetchall()
		for dbEntry in dbWriteList:
			os.makedirs(dbEntry[2], exist_ok=True) #Always tries to create dir for any new file (ACTION = 1/2). Empty dirs are removed in delete()
			if dbEntry[4] == 2: copyfile(dbEntry[0] + dbEntry[1], dbEntry[2] + dbEntry[3])

def encode(fileType,quality, threadCount, quiet):
	#Prepares encoding tasks for workers and cleans up once jobs are complete
	con = sqlite3.connect('files.db')
	with con:
		cur = con.cursor()
		cur.execute("SELECT SRC_PATH, SRC_NAME, DEST_PATH, DEST_NAME FROM FILES WHERE ACTION = 1")
		dbEncodeList = cur.fetchall()
	encodeQueue = multiprocessing.JoinableQueue()
	counter = 0
	for dbEntry in dbEncodeList:
		counter += 1
		encodeQueue.put(dbEntry)
	if quiet == 0: print("Files to be encoded: ", counter)
	if quiet == 0 and counter != 0: print("Using ", threadCount, " Threads")
	encodePool = multiprocessing.Pool(threadCount, encode_worker, (encodeQueue, fileType, quality, quiet))
	encodeQueue.join()
	encodePool.terminate()
	return

def encode_worker(queue, fileType ,quality, quiet):
	while True:
		currentEntry = queue.get()
		if quiet == 0: print("Encoding ", currentEntry[3])
		if fileType == ".mp3":
			subprocess.check_call(['ffmpeg', '-nostats', '-loglevel', '0', '-i', currentEntry[0] + currentEntry[1], '-q:a', str(quality), '-map_metadata', '0', '-id3v2_version', ' 3', '-threads', '1', '-y', currentEntry[2] + currentEntry[3]])
			queue.task_done()
		elif fileType == ".opus":
			subprocess.check_call(['ffmpeg', '-nostats', '-loglevel', '0', '-i', currentEntry[0] + currentEntry[1], '-acodec', 'libopus', '-b:a', quality, '-threads', '1', '-y', currentEntry[2] + currentEntry[3]])
			queue.task_done()
			pass
	return

def delete():
	con = sqlite3.connect('files.db')
	with con:
		cur = con.cursor()
		cur.execute("SELECT DEST_PATH, DEST_NAME, SRC_PATH, SRC_NAME FROM FILES WHERE ACTION = 3")
		dbDeleteList = cur.fetchall()
	for dbEntry in dbDeleteList:
		os.remove(dbEntry[0] + dbEntry[1])
		try: os.removedirs(dbEntry[0]) #recusrively remove directories until we hits a non-empty one
		except OSError as e:
			if e.errno == os.errno.ENOTEMPTY: continue
			else: raise
	with con:
		cur = con.cursor()
		cur.execute("DELETE FROM FILES WHERE ACTION = 3") #actually remove the db entries
	con.close()
	return

def main():
	check_requirements()

	parser = argparse.ArgumentParser()
	parser.add_argument("sourcePath")
	parser.add_argument("targetPath")
	parser.add_argument("-m", "--mode", help="Select mirroring mode. 'flac-only' only transcodes flac files while 'contents' mirrors all files within the directory. Default: 'flac-only'", choices=["flac-only", "contents"], default="flac-only")
	parser.add_argument("--quiet", help="Only output error messages", action="store_true")
	parser.add_argument("-o", "--output", help="Select file format and exension. Default: 'mp3'", choices=["mp3", "opus"], default="mp3")
	parser.add_argument("-q", "--quality", help="Select output quality. Equals the corresponding VBR setting (e.g. '-q 0' = VBR-0 (245 kbit/s). Default: '0'", type=int, choices=range(0,10), default=0)
	parser.add_argument("--force", help="Remove database, then re-encode/tansfer all files (DANGEROUS)", action="store_true")
	args = parser.parse_args()

	threadCount = multiprocessing.cpu_count()
	mirrorMode = args.mode
	srcPath = args.sourcePath
	destPath = args.targetPath
	#Make sure that both paths end with a /
	if srcPath[-1:] != '/': srcPath = srcPath + '/'
	if destPath[-1:] != '/':destPath = destPath + '/'
	if args.quiet == True: quiet = 1
	else: quiet = 0
	if args.force == True:
		con = sqlite3.connect('files.db')
		with con:
			cur = con.cursor()
			cur.execute("DROP TABLE IF EXISTS FILES")

	if args.output == "opus":
		fileType = ".opus"
		qualityDict = {0:"245k", 1:"225k", 2:"190k", 3:"175k", 4:"165k", 5:"130k", 6:"115k", 7:"100k", 8:"85k", 9:"65k"}
		quality = qualityDict[args.quality]
	elif args.output == "mp3":
		fileType = ".mp3"
		quality = args.quality

	if quiet == 0: print("Starting Initialization of Database - this can take a while...")
	initialize_db(srcPath,destPath,fileType,mirrorMode)
	if quiet == 0 and mirrorMode == "contents": print("Starting mirror of filesystem...")
	mirror_directories()
	if quiet == 0: print("Starting encoding tasks...")
	encode(fileType,quality,threadCount,quiet)
	if quiet == 0: print("Removing deleted files...")
	delete()

if __name__ == "__main__":
    main()




