From 64266990567bcd7d7af1454c83952bf6de295bf2 Mon Sep 17 00:00:00 2001 From: brent s Date: Thu, 23 Mar 2017 19:39:07 -0400 Subject: [PATCH] initial commit --- blank.schema.sql | 65 ++++++ podloader.ini.dist | 153 +++++++++++++ podloader.py | 552 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 770 insertions(+) create mode 100644 blank.schema.sql create mode 100644 podloader.ini.dist create mode 100755 podloader.py diff --git a/blank.schema.sql b/blank.schema.sql new file mode 100644 index 0000000..ac00666 --- /dev/null +++ b/blank.schema.sql @@ -0,0 +1,65 @@ +-- MySQL dump 10.16 Distrib 10.1.21-MariaDB, for Linux (x86_64) +-- +-- Host: db.domain.tld Database: myDB +-- ------------------------------------------------------ +-- Server version 10.1.21-MariaDB + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!40101 SET NAMES utf8 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `myTBL` +-- + +DROP TABLE IF EXISTS `myTBL`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `myTBL` ( + `episode` varchar(8) NOT NULL, + `file_prefix` varchar(255) NOT NULL, + `sha_mp3` char(64) NOT NULL, + `sha_ogg` char(64) NOT NULL, + `bytesize_mp3` int(16) NOT NULL, + `bytesize_ogg` int(16) NOT NULL, + `length` int(8) NOT NULL, + `editor` varchar(64) NOT NULL, + `intro_title` varchar(128) NOT NULL, + `intro_artist` varchar(128) NOT NULL, + `intro_link` varchar(256) NOT NULL, + `intro_copyright` varchar(45) NOT NULL, + `intro_copyrightlink` varchar(256) NOT NULL, + `outro_title` varchar(128) NOT NULL, + `outro_artist` varchar(128) NOT NULL, + `outro_link` varchar(256) NOT NULL, + `outro_copyright` varchar(45) NOT NULL, + `outro_copyrightlink` varchar(256) NOT NULL, + `recorded` datetime NOT NULL, + `released` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `changed` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`episode`), + UNIQUE KEY `episode_UNIQUE` (`episode`), + UNIQUE KEY `file_prefix_UNIQUE` (`file_prefix`), + UNIQUE KEY `sha_mp3_UNIQUE` (`sha_mp3`), + UNIQUE KEY `sha_ogg_UNIQUE` (`sha_ogg`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2017-03-23 18:19:43 diff --git a/podloader.ini.dist b/podloader.ini.dist new file mode 100644 index 0000000..5453655 --- /dev/null +++ b/podloader.ini.dist @@ -0,0 +1,153 @@ +## Podloader config ## + +[rsync] +# The server the files go to +host = domain.tld + +# The remote path root. This must be a full/absolute path! +path = /srv/http/myVhostDir + +# The remote user +user = sshuser + +[mysql] +# The mysql server. Note that this will be overridden if you +# use a .my.cnf and a host is specified in there. +host = db.${rsync:host} + +# The mysql server's port. Note that this will be overridden +# if you use a .my.cnf and a port is specified in there. +port = 3306 + +# The mysql user. Note that this will be overridden if you +# use a .my.cnf and a user is specified in there. +user = mysqluser + +# The mysql DB. Note that this will be overridden if you +# use a .my.cnf and a user is specified in there. +db = myDB + +# The mysql table +table = myTBL + +# The column names (separated by commas) for, in order: +# episode ID (e.g. "S1E2") +# file_prefix (the filename only WITHOUT .ogg/.mp3) +# sha_mp3 (the column to hold the SHA256 of the MP3 file) +# sha_ogg (the column to hold the SHA256 of the OGG file) +# bytesize_mp3 (size of the MP3 file in bytes) +# bytesize_ogg (size of the OGG file in bytes) +# length (the length of the track in seconds) +# editor (the name of the person that edited the audio track(s) +# intro_title (the title of the intro music track) +# intro_artist (the artist that composed the intro music track) +# intro_link (a URL to the intro track or artist's site/page) +# intro_copyright (the copyright license for the intro track, e.g. "CC-BY-SA 3.0") +# intro_copyrightlink (a URL to the full terms of the intro track's copyright) +# outro_title (the title of the outro music track) +# outro_artist (the artist that composed the outro music track) +# outro_link (a URL to the outro track or artist's site/page) +# outro_copyright (the copyright license for the outro track, e.g. "CC-BY-SA 3.0") +# outro_copyrightlink (a URL to the full terms of the outro track's copyright) +# recorded (when the episode was recorded) +# released (when the episode was released) +# +# Note that a dump of the *table* is included (blank.schema.sql). Feel free to use it: +# mysql -e "CREATE DATABASE myDB" && mysql myDB < blank.schema.sql +# This will create a database named "myDB" (you can skip that part if you already have a database), +# and create a table named "myTBL" according to the default spec outlined in here. +# +cols = episode,file_prefix,sha_mp3,sha_ogg,bytesize_mp3,bytesize_ogg,length,editor,intro_title,intro_artist,intro_link,intro_copyright,intro_copyrightlink,outro_title,outro_artist,outro_link,outro_copyright,outro_copyrightlink,recorded,released + +# The remote mysql password - if this is set to False/no/0, +# we'll just use the my.cnf-formatted INI file (e.g. ~/.my.cnf) instead. +password = False + +# If the above is False, path to the .my.cnf +conf = ~/.my.cnf + +# If password is False, what [client] section suffix should we use? +# Note that this is going to look like e.g. [clientremote1] in the config +# file. (correlates to mysql's --defaults-group-suffix=) +confsec = remote1 + +[gpg] +# Should we actually sign episodes? True/yes/1 or False/no/0. +enabled = True + +# The GPG key ID(s) (in a comma-separated list) to sign the episode with. +# You must have the private key in your *local* keyring! +keys = D34DB33FD34DB33FD34DB33FD34DB33FD34DB33F + +# The path to your GNUPG homedir. +homedir = ~/.gnupg + +[local] +# The local path root to the edited FLAC files +path = ~/podcast + +# A subdir for the episode-specific files. If it contains one of the following values, +# substitution will be done. +# Special values: +# - SEASONEPISODE = A special string that uses the -s/--season and -e/--episode strings together. +# i.e. if season is 1 and episode is 13, it'd be "s1e13". +# - SEASON = A special string that uses -s/--season. +# - EPISODE = A special string that uses -e/--episode. +subdir = SEASONEPISODE + +# Where the transcoded media and GPG sigs (if enabled) should go +# (in a structure of ///{mp3,ogg,gpg}/) +mediadir = ${path}/releases + +[tags] +# What should the Artist string be? +artist = Podcastin' Joe + +# What should the Album name be? +# If you set this to SEASON, it will set this to whatever's specified for -s/--season +album = SEASON + +# How many digits should the season be padded to? (i.e. the minimum number of digits) +# A pad of three would have Season 3 be "003". +season_pad = 1 + +# How many digits should the episode be padded to? (i.e. the minimum number of digits) +# A pad of three would have Episode 1 be "001". +episode_pad = 1 + +# What should the Year be set to? +# If set as False/no/0, it will be automatically determined by the raw media file's metadata. +year = False + +# What track number should be set? +# If set as EPISODE, it will set this to whatever's specified for -e/--episode +track = EPISODE + +# What genre should be set? +genre = Podcast + +# What should be set as the comment field? +comment = https://podcast.domain.tld + +# What should be set as the Copyright notice? +copyright = CC-BY-SA 4.0 + +# What should be set for the URL field? +# Special values: +# - SEASONEPISODE = A special string that uses the -s/--season and -e/--episode strings together. +# i.e. if season is 1 and episode is 13, it'd be "S1E13". +# - SEASON = A special string that uses -s/--season. +# - EPISODE = A special string that uses -e/--episode. +url = ${comment}/episodes/SEASONEPISODE + +# Who encoded the file? (e.g. what is your name) +encoded = Joe Schmoe + +# Who edited the episode? (see -d/--editor) +# Note that this can contain (and should, if available) +# contain a link (e.g.: +# Editor Name ) +editor = Some Editor + +# A local path to the image to embed. +img = ${local:path}/images/podcast_logo.jpg diff --git a/podloader.py b/podloader.py new file mode 100755 index 0000000..0960df2 --- /dev/null +++ b/podloader.py @@ -0,0 +1,552 @@ +#!/usr/bin/env python3 + + +import configparser +import argparse +import os +import re +import base64 +import subprocess +import hashlib +import datetime +import pymysql +import magic +import gpgme +from mutagen.id3 import ID3, APIC, TALB, TDRC, TENC, TRCK, COMM, WXXX, TCON, TIT2, TPE1, TCOP +from mutagen.oggvorbis import OggVorbis +from mutagen.flac import Picture + +dflt_config_paths = ['~/.podloader.ini', + '~/.podloader/podloader.ini', + 'podloader.ini', + 'podloader.ini.dist'] + +def configParse(configfile = dflt_config_paths[-1]): + # Here we find and parse the config, then return a dict of the values. + # We COULD return a configparser object, but that's a PITA to reference. + conf = configfile + olddef = dflt_config_paths[-1] + for i, item in enumerate(dflt_config_paths): + dflt_config_paths[i] = os.path.expanduser(item) + for i, item in enumerate(dflt_config_paths): + if not dflt_config_paths[i].startswith('/'): + dflt_config_paths[i] = '{0}/{1}'.format(os.path.dirname(os.path.realpath(__file__)), item) + if configfile != olddef: + conf = configfile + else: + for p in dflt_config_paths: + if os.path.isfile(p): + conf = p + break + defconf = dflt_config_paths[-1] + config = configparser.ConfigParser() + config._interpolation = configparser.ExtendedInterpolation() + config.read([defconf, conf]) + config_dict = {s:dict(config.items(s)) for s in config.sections()} + # Convert the booleans to pythonic booleans in the dict, convert to ints, etc. + if config['mysql']['password'] == 'False': + config_dict['mysql']['password'] = config['mysql'].getboolean('password') + config_dict['gpg']['enabled'] = config['gpg'].getboolean('enabled') + config_dict['mysql']['port'] = config['mysql'].getint('port') + config_dict['tags']['season_pad'] = config['tags'].getint('season_pad') + config_dict['tags']['episode_pad'] = config['tags'].getint('episode_pad') + # Set some "magic" interpolation + if not config_dict['mysql']['password']: + config_dict['mysql']['conf'] = os.path.expanduser(config_dict['mysql']['conf']) + mysqlconf = configparser.ConfigParser(allow_no_value = True) + if os.path.isfile(config_dict['mysql']['conf']): + mysqlconf.read(config_dict['mysql']['conf']) + mysqlcnf_dict = {s:dict(mysqlconf.items(s)) for s in mysqlconf.sections()} + mysqlcnf = mysqlcnf_dict['client' + config_dict['mysql']['confsec']] + if 'host' in mysqlcnf: + config_dict['mysql']['host'] = mysqlcnf['host'] + else: + config_dict['mysql']['host'] = 'localhost' + if 'ssl' in mysqlcnf: + config_dict['mysql']['ssl'] = {} + for c in ('ssl-ca','ssl-cert', 'ssl-key', 'ssl-cipher'): + if c in mysqlcnf: + newkey = c.replace('ssl-', '') + config_dict['mysql']['ssl'][newkey] = mysqlcnf[c] + config_dict['mysql']['user'] = mysqlcnf['user'] + config_dict['mysql']['password'] = mysqlcnf['password'] + if 'port' in mysqlcnf: + config_dict['mysql']['port'] = int(mysqlcnf['port']) + else: + config_dict['mysql']['port'] = 3306 + del config_dict['mysql']['confsec'] + mysqlcnf.clear() + mysqlcnf_dict.clear() + for s in mysqlconf.sections(): + mysqlconf.remove_section(s) + else: + exit('ERROR: You specified [mysql]password as False but did not provide a valid .my.cnf path!') + config_dict['gpg']['keys'] = config_dict['gpg']['keys'].split(',') + if len(config_dict['gpg']['keys']) >= 1: + config_dict['gpg']['keys'][:] = [re.sub('^\s*(0x)?([0-9A-F]*)\s*', + '\g<2>', x).upper() for x in config_dict['gpg']['keys']] + if config_dict['gpg']['enabled'] == True: + if config_dict['gpg']['homedir'] != '': + config_dict['gpg']['homedir'] = os.path.expanduser(config_dict['gpg']['homedir']) + config_dict['local']['path'] = os.path.expanduser(config_dict['local']['path']) + config_dict['local']['mediadir'] = os.path.expanduser(config_dict['local']['mediadir']) + os.makedirs(config_dict['local']['mediadir'], exist_ok = True) + if config_dict['tags']['year'] == 'False': + config_dict['tags']['year'] = config['tags'].getboolean('year') + config_dict['tags']['img'] = os.path.expanduser(config_dict['tags']['img']) + return(config_dict) + +def confArgs(conf, args): + conf['episode'] = {} + conf['episode']['title'] = args.title + conf['episode']['file_title'] = re.sub('[^A-Za-z0-9-]', '.', conf['episode']['title']).lower() + conf['episode']['season'] = str(args.season).zfill(conf['tags']['season_pad']) + conf['episode']['serial'] = str(args.episode).zfill(conf['tags']['episode_pad']) + for i in ('season', 'episode'): + del conf['tags'][i + '_pad'] + conf['episode']['id'] = 'S{0}E{1}'.format(str(conf['episode']['season']), + str(conf['episode']['serial'])) + conf['episode']['pretty_title'] = '{0}: {1}'.format(conf['episode']['id'], conf['episode']['title']) + if conf['tags']['track'] == 'EPISODE': + conf['tags']['track'] = conf['episode']['serial'] + conf['tags']['url'] = re.sub('^(.*)SEASONEPISODE(.*)$', + '\g<1>' + conf['episode']['id'] + '\g<2>', + conf['tags']['url']) + conf['tags']['url'] = re.sub('^(.*)SEASON(.*)$', + '\g<1>' + conf['episode']['season'] + '\g<2>', + conf['tags']['url']) + conf['tags']['url'] = re.sub('^(.*)EPISODE(.*)$', + '\g<1>' + conf['episode']['serial'] + '\g<2>', + conf['tags']['url']) + conf['local']['subdir'] = re.sub('^(.*)SEASONEPISODE(.*)$', + '\g<1>' + conf['episode']['id'] + '\g<2>', + conf['local']['subdir']) + conf['local']['subdir'] = re.sub('^(.*)SEASON(.*)$', + '\g<1>' + conf['episode']['season'] + '\g<2>', + conf['local']['subdir']) + conf['local']['subdir'] = re.sub('^(.*)EPISODE(.*)$', + '\g<1>' + conf['episode']['serial'] + '\g<2>', + conf['local']['subdir']) + conf['local']['path'] = '{0}/{1}'.format(conf['local']['path'], + conf['local']['subdir'].lower()) + if not conf['tags']['year']: + conf['tags']['year'] = datetime.datetime.now().year + conf['tags']['year'] = str(conf['tags']['year']) + if not os.path.isdir(conf['local']['path']): + os.makedirs(conf['local']['path'], exist_ok = True) + del conf['local']['subdir'] + conf['episode']['raw'] = args.flacfile + if args.flacfile: + newpath = os.path.abspath(os.path.expanduser(args.flacfile)) + if os.path.isfile(newpath): + conf['episode']['raw'] = newpath + else: + exit('ERROR: The FLAC file you specified does not seem to exist({0}). Check your path.'.format(newpath)) + else: + dflt_flac_names = ['{0}.edited.flac'.format(conf['episode']['id'].lower()), + '{0}.final.flac'.format(conf['episode']['id'].lower()), + '{0}.flac'.format(conf['episode']['id'].lower())] + for f in dflt_flac_names: + if os.path.isfile('{0}/{1}'.format(conf['local']['path'], f)): + conf['episode']['raw'] = '{0}/{1}'.format(conf['local']['path'], f) + break + if not conf['episode']['raw']: + exit('ERROR: We cannot seem to locate a FLAC to convert. Try using the -f/--file argument.') + magic_file = magic.open(magic.MAGIC_MIME) + magic_file.load() + if not magic_file.file(conf['episode']['raw']) == 'audio/x-flac; charset=binary': + exit('ERROR: Your FLAC file does not seem to actually be FLAC.') + conf['flac'] = {} + conf['flac']['samples'] = subprocess.check_output(['metaflac', + '--show-total-samples', + '{0}'.format(conf['episode']['raw'])]).decode('utf-8').strip() + conf['flac']['rate'] = subprocess.check_output(['metaflac', + '--show-sample-rate', + '{0}'.format(conf['episode']['raw'])]).decode('utf-8').strip() + conf['flac']['rate'] = '{0:.2f}'.format(float(conf['flac']['rate'])) + rawfilepath = os.path.abspath(os.path.expanduser(args.raw_recording)) + if not os.path.isfile(rawfilepath): + exit('ERROR: the raw recording evaluated to {0} but it does not seem to exist!'.format(rawfilepath)) + conf['episode']['recorded'] = (str(datetime.datetime.utcfromtimestamp(os.path.getmtime(rawfilepath)))).split('.')[0] + conf['episode']['length'] = float(conf['flac']['samples'])/float(conf['flac']['rate']) + conf['episode']['length'] = str(int(conf['episode']['length'])) + if args.now: + timestamp = datetime.datetime.timestamp(datetime.datetime.now()) + else: + timestamp = os.path.getmtime(conf['episode']['raw']) + conf['episode']['sha'] = {} + conf['episode']['size'] = {} + conf['episode']['released'] = (str(datetime.datetime.utcnow())).split('.')[0] + conf['episode']['month'] = datetime.datetime.fromtimestamp(timestamp).strftime('%m') + conf['episode']['day'] = datetime.datetime.fromtimestamp(timestamp).strftime('%d') + conf['episode']['file_title'] = re.sub('\.+', '.', conf['episode']['file_title']) + conf['episode']['file_title'] = '{0}.{1}'.format(conf['episode']['id'].lower(), + re.sub('\.$', + '', + conf['episode']['file_title'])) + del conf['flac'] + if args.editor: + del conf['tags']['editor'] + conf['episode']['editor'] = args.editor + else: + conf['episode']['editor'] = conf['tags']['editor'] + if conf['tags']['album'] == 'SEASON': + conf['tags']['album'] = 'Season {0}'.format(conf['episode']['season']) + conf['local']['mediadir'] = '{0}/S{1}/E{2}'.format(conf['local']['mediadir'], + conf['episode']['season'], + conf['episode']['serial']) + os.makedirs(conf['local']['mediadir'], exist_ok = True) + cc_base_url = 'https://creativecommons.org/licenses' + conf['music'] = {} + conf['music']['intro'] = {} + conf['music']['intro']['artist'] = args.intro_artist + conf['music']['intro']['title'] = args.intro_title + conf['music']['intro']['copyright'] = args.intro_copyright + conf['music']['intro']['link'] = args.intro_link + if args.intro_copyrightlink: + conf['music']['intro']['copyrightlink'] = args.intro_copyrightlink + else: + strp_cr = (re.sub('CC-?', '', args.intro_copyright, flags = re.I)).split() + if len(strp_cr) != 2: + exit('ERROR: You did not specify a copyright link and this does not seem to be a CC license!') + conf['music']['intro']['copyrightlink'] = '{0}/{1}/{2}/'.format( + cc_base_url, + strp_cr[0].lower(), + strp_cr[1]) + conf['music']['outro'] = {} + conf['music']['outro']['artist'] = args.outro_artist + conf['music']['outro']['title'] = args.outro_title + conf['music']['outro']['copyright'] = args.outro_copyright + conf['music']['outro']['link'] = args.outro_link + if args.intro_copyrightlink: + conf['music']['outro']['copyrightlink'] = args.outro_copyrightlink + else: + strp_cr = (re.sub('CC-?', '', args.outro_copyright, flags = re.I)).split() + conf['music']['outro']['copyrightlink'] = '{0}/{1}/{2}/'.format( + cc_base_url, + strp_cr[0].lower(), + strp_cr[1]) + return(conf) + +def transcodeMP3(conf): + mediatype = 'mp3' + mediadir = '{0}/{1}'.format(conf['local']['mediadir'], mediatype) + mediafile = '{0}/{1}.{2}'.format(mediadir, + conf['episode']['file_title'], + mediatype) + if os.path.isfile(mediafile): + os.remove(mediafile) + os.makedirs(mediadir, exist_ok = True) + print('{0}: Transcoding to {1}...'.format(datetime.datetime.now(), mediatype)) + subprocess.call(['ffmpeg', '-stats', '-loglevel', '0', '-i', + conf['episode']['raw'], '-b:a', '128k', '-ac','1', '-joint_stereo', '1', + mediafile]) + return(mediafile) + +def transcodeOGG(conf): + mediatype = 'ogg' + mediadir = '{0}/{1}'.format(conf['local']['mediadir'], mediatype) + mediafile = '{0}/{1}.{2}'.format(mediadir, + conf['episode']['file_title'], + mediatype) + if os.path.isfile(mediafile): + os.remove(mediafile) + os.makedirs(mediadir, exist_ok = True) + print('{0}: Transcoding to {1}...'.format(datetime.datetime.now(), mediatype)) + subprocess.call(['ffmpeg', '-stats', '-loglevel', '0', '-i', + conf['episode']['raw'], '-qscale:a', '8', '-ac','1', '-joint_stereo', '1', + mediafile]) + return(mediafile) + +def tagMP3(conf, mediafile): + # This appears to not work. + # http://id3.org/id3v2.3.0#Attached_picture + # http://id3.org/id3v2.4.0-frames (section 4.14) + # https://stackoverflow.com/questions/7275710/mutagen-how-to-detect-and-embed-album-art-in-mp3-flac-and-mp4 + # https://stackoverflow.com/questions/409949/how-do-you-embed-album-art-into-an-mp3-using-python + magic_file = magic.open(magic.MAGIC_MIME) + magic_file.load() + imgmime = magic_file.file(conf['tags']['img']).split(';')[0] + with open(conf['tags']['img'], 'rb') as f: + img_data = f.read() + print('{0}: Now adding tags to {1}...'.format(datetime.datetime.now(), mediafile)) + tag = ID3(mediafile) + tag.add(TALB(encoding = 0, text = [conf['tags']['album']])) + tag.add(APIC(encoding = 0, mime = imgmime, type = 3, + desc = conf['tags']['artist'], data = img_data)) + tag.add(TDRC(encoding = 0, text = ['{0}.{1}.{2}'.format(conf['tags']['year'], + conf['episode']['month'], + conf['episode']['day'])])) + tag.add(TENC(encoding = 0, text = [conf['tags']['encoded']])) + tag.add(TRCK(encoding = 0, text = [conf['tags']['track']])) + tag.add(COMM(encoding = 0, lang = '\x00\x00\x00', desc = '', + text = [conf['tags']['comment']])) + tag.add(WXXX(encoding = 0, desc = '', url = conf['tags']['url'])) + tag.add(TCON(encoding = 0, text = [conf['tags']['genre']])) + tag.add(TIT2(encoding = 0, text = [conf['episode']['pretty_title']])) + tag.add(TPE1(encoding = 0, text = [conf['tags']['artist']])) + tag.add(TCOP(encoding = 0, text = [conf['tags']['copyright']])) + tag.save() + +def tagOGG(conf, mediafile): + # It seems we can't use this method. + # https://mutagen.readthedocs.io/en/latest/user/vcomment.html + # https://wiki.xiph.org/VorbisComment#METADATA_BLOCK_PICTURE + # https://xiph.org/flac/format.html#metadata_block_picture + # https://github.com/quodlibet/mutagen/issues/200 + magic_file = magic.open(magic.MAGIC_MIME) + magic_file.load() + imgmime = magic_file.file(conf['tags']['img']).split(';')[0] + with open(conf['tags']['img'], 'rb') as f: + img_b64 = base64.b64encode(f.read()) + print('{0}: Now adding tags to {1}...'.format(datetime.datetime.now(), mediafile)) + tag = OggVorbis(mediafile) + tag['TITLE'] = conf['episode']['pretty_title'] + tag['ARTIST'] = conf['tags']['artist'] + tag['ALBUM'] = conf['tags']['album'] + tag['DATE'] = '{0}.{1}.{2}'.format(conf['tags']['year'], + conf['episode']['month'], + conf['episode']['day']) + tag['TRACKNUMBER'] = conf['tags']['track'] + tag['GENRE'] = conf['tags']['genre'] + tag['DESCRIPTION'] = conf['tags']['comment'] + tag['COPYRIGHT'] = conf['tags']['copyright'] + tag['CONTACT'] = conf['tags']['url'] + tag['ENCODED-BY'] = conf['tags']['encoded'] + tag['ENCODER'] = conf['tags']['encoded'] + tag['METADATA_BLOCK_PICTURE'] = img_b64.decode('utf-8') + tag.save() + +def getSHA256(mediafile): + print('{0}: Generating SHA256 for {1}...'.format(datetime.datetime.now(), + mediafile)) + filehash = hashlib.sha256() + with open(mediafile, 'rb') as f: + for chunk in iter(lambda: f.read(4096), b""): + filehash.update(chunk) + return(filehash.hexdigest()) + +def getSize(mediafile): + filesize = os.path.getsize(mediafile) + return(filesize) + +def dbEntry(conf): + print('{0}: Inserting into the {1}.{2}@{3} table...'.format(datetime.datetime.now(), + conf['mysql']['db'], + conf['mysql']['table'], + conf['mysql']['host'])) + ssl = False + if 'ssl' in conf['mysql']: + ssl = conf['mysql']['ssl'] + vals = "'{0}','{1}','{2}','{3}','{4}','{5}','{6}','{7}','{8}','{9}','{10}','{11}','{12}','{13}','{14}','{15}','{16}','{17}','{18}','{19}'".format(conf['episode']['id'], + conf['episode']['file_title'], + conf['episode']['sha']['mp3'], + conf['episode']['sha']['ogg'], + conf['episode']['size']['mp3'], + conf['episode']['size']['ogg'], + conf['episode']['length'], + re.sub("'", "\\'", conf['episode']['editor']), + re.sub("'", "\\'", conf['music']['intro']['title']), + re.sub("'", "\\'", conf['music']['intro']['artist']), + conf['music']['intro']['link'], + re.sub("'", "\\'", conf['music']['intro']['copyright']), + conf['music']['intro']['copyrightlink'], + re.sub("'", "\\'", conf['music']['outro']['title']), + re.sub("'", "\\'", conf['music']['outro']['artist']), + conf['music']['outro']['link'], + re.sub("'", "\\'", conf['music']['outro']['copyright']), + conf['music']['outro']['copyrightlink'], + conf['episode']['recorded'], + conf['episode']['released']) + + + conn = pymysql.connect(host = conf['mysql']['host'], + port = conf['mysql']['port'], + user = conf['mysql']['user'], + passwd = conf['mysql']['password'], + db = conf['mysql']['db'], + ssl = ssl, + autocommit = True) + cur = conn.cursor() + query = 'INSERT INTO {0} ({1}) VALUES ({2})'.format(conf['mysql']['table'], + conf['mysql']['cols'], + vals) + try: + cur.execute(query) + cur.close() + conn.close() + except: + print('{0}: There seems to have been some error when inserting into the DB. Check access (or it is a dupe).'.format( + datetime.datetime.now())) + +def signEp(mediatype): + os.makedirs('{0}/gpg'.format(conf['local']['mediadir']), exist_ok = True) + sigfile = '{0}/gpg/{1}.{2}.asc'.format(conf['local']['mediadir'], + conf['episode']['file_title'], + mediatype) + os.environ['GNUPGHOME'] = conf['gpg']['homedir'] + vrfykeys = [] + sigs = {} + gpg = gpgme.Context() + gpg.armor = True + for k in conf['gpg']['keys']: + if gpg.get_key(k, True).can_sign: + # it seems pygpgme does not allow signing with subkeys. sad day. gpg.signkeys complains if you pass it Subkey objects. + #subkeys = [] + #for i in gpg.get_key(k, True).subkeys: + # subkeys.append(i.fpr) + #indexnum = [x for x, s in enumerate(subkeys) if k in s][0] + #vrfykeys.append(gpg.get_key(k, True).subkeys[indexnum].fpr) + if gpg.get_key(k, True).subkeys[0].fpr not in vrfykeys: + vrfykeys.append(gpg.get_key(k, True).subkeys[0].fpr) + data_in = '{0}/{1}/{2}.{3}'.format(conf['local']['mediadir'], + mediatype, + conf['episode']['file_title'], + mediatype) + print('{0}: Checking for existing GPG signatures (and skipping if we signed)...'.format(datetime.datetime.now())) + if os.path.isfile(sigfile): + with open(sigfile, 'rb') as s: + with open(data_in, 'rb') as f: + for k in gpg.verify(s, f, None): + try: + sigs[gpg.get_key(k.fpr, True).subkeys[0].fpr] = True + except: + pass + for k in vrfykeys: + if k not in sigs: + sigkeys = [] + if gpg.get_key(k, True).can_sign: + print('{0}: Signing with key {1}...'.format(datetime.datetime.now(), + k)) + sigkeys.append(gpg.get_key(k, True)) + gpg.signers = sigkeys + with open(sigfile, 'ab') as s: + with open(data_in, 'rb') as f: + gpg.sign(f, s, gpgme.SIG_MODE_DETACH) + return(sigfile) + +def uploadFile(): + print('{0}: Syncing files to server...'.format(datetime.datetime.now())) + subprocess.call(['rsync', + '-a', + '{0}'.format(conf['local']['mediadir']), + '{0}@{1}:{2}S{3}/.'.format(conf['rsync']['user'], + conf['rsync']['host'], + conf['rsync']['path'], + conf['episode']['season'])]) + +def argParse(): + parser = argparse.ArgumentParser( + description = 'PodLoader - a script to assist in Textpattern-powered podcasts', + prog = 'podloader v1.0') + requiredArgs = parser.add_argument_group('REQUIRED arguments') + requiredArgs.add_argument('-t', + '--title', + dest = 'title', + required = True, + help = "The episode's title (as it will appear in meta information).") + requiredArgs.add_argument('-e', + '--episode', + dest = 'episode', + required = True, + type = int, + help = "The episode number for this episode.") + requiredArgs.add_argument('-s', + '--season', + dest = 'season', + required = True, + type = int, + help = "The season number this episode is in.") + requiredArgs.add_argument('-r', + '--raw-recording', + dest = 'raw_recording', + required = True, + help = "The path to a single-track *raw* recording. This file is used to get the timestamp of recording.") + requiredArgs.add_argument('-i:a', + '--intro-artist', + dest = 'intro_artist', + required = True, + help = "The artist for the intro music.") + requiredArgs.add_argument('-i:t', + '--intro-title', + dest = 'intro_title', + required = True, + help = "The title for the intro music.") + requiredArgs.add_argument('-i:l', + '--intro-link', + dest = 'intro_link', + required = True, + help = "The link to the intro track (i.e. page to more information about the track).") + requiredArgs.add_argument('-i:c', + '--intro-copyright', + dest = 'intro_copyright', + required = True, + help = "The copyright for the intro music. If it's a Creative Commons type, you do not need to include a copyright link. e.g. '-i:c \"CC-BY-SA 3.0\"'") + requiredArgs.add_argument('-o:a', + '--outro-artist', + dest = 'outro_artist', + required = True, + help = "The artist for the outro music.") + requiredArgs.add_argument('-o:t', + '--outro-title', + dest = 'outro_title', + required = True, + help = "The title for the outro music.") + requiredArgs.add_argument('-o:l', + '--outro-link', + dest = 'outro_link', + required = True, + help = "The link to the outro track (i.e. page to more information about the track).") + requiredArgs.add_argument('-o:c', + '--outro-copyright', + dest = 'outro_copyright', + required = True, + help = "The copyright for the outro music. If it's a Creative Commons type, you do not need to include a copyright link. e.g. '-i:c \"CC-BY-SA 3.0\"'") + parser.add_argument('-i:cl', + '--intro-copyrightlink', + dest = 'intro_copyrightlink', + default = False, + help = "The link to the copyright terms for the intro. Optional if it's a CC license.") + parser.add_argument('-o:cl', + '--outro-copyrightlink', + default = False, + dest = 'outro_copyrightlink', + help = "The link to the copyright terms for the outro. Optional if it's a CC license.") + parser.add_argument('-d', + '--editor', + dest = 'editor', + help = 'The audio editor for the episode. Can (should) contain HTML link to editor (e.g. \'Editor Name\'') + parser.add_argument('-f', + '--file', + dest = 'flacfile', + default = False, + help = "The (final edit) FLAC file to be used for the episode. If not specified, we'll try to guess.") + parser.add_argument('-n', + '--now', + dest = 'now', + default = False, + action = 'store_true', + help = "Instead of getting the date based on the time of the file, use today's date (for media tags).") + try: + args = parser.parse_args() + print('{0}: Starting.'.format(datetime.datetime.now())) + except (NameError, TypeError): + parser.print_help() + exit(1) + return(args) + +if __name__ == "__main__": + conf = confArgs(configParse(), argParse()) + mp3 = transcodeMP3(conf) + tagMP3(conf, mp3) + ogg = transcodeOGG(conf) + tagOGG(conf, ogg) + conf['episode']['sha']['mp3'] = getSHA256(mp3) + conf['episode']['sha']['ogg'] = getSHA256(ogg) + conf['episode']['size']['mp3'] = getSize(mp3) + conf['episode']['size']['ogg'] = getSize(ogg) + dbEntry(conf) + signEp('mp3') + signEp('ogg') + uploadFile() + print('{0}: Finished.'.format(datetime.datetime.now()))