diff --git a/airtime_mvc/application/common/FileDataHelper.php b/airtime_mvc/application/common/FileDataHelper.php index 3c576f685..d9e8b638a 100644 --- a/airtime_mvc/application/common/FileDataHelper.php +++ b/airtime_mvc/application/common/FileDataHelper.php @@ -155,20 +155,8 @@ class FileDataHelper { } else { $ext = 'jpg'; } + self::resizeGroup($file, $ext); - if (file_exists($file)) { - self::resizeImage($file, $file . '-32.jpg', $ext, 32, 100); - self::resizeImage($file, $file . '-64.jpg', $ext, 64, 100); - self::resizeImage($file, $file . '-128.jpg', $ext, 128, 100); - self::resizeImage($file, $file . '-256.jpg', $ext, 256, 100); - self::resizeImage($file, $file . '-512.jpg', $ext, 512, 100); - self::imgToDataURI($file . '-32.jpg', $file . '-32'); - self::imgToDataURI($file . '-64.jpg', $file . '-64'); - self::imgToDataURI($file . '-128.jpg', $file . '-128'); - self::imgToDataURI($file . '-256.jpg', $file . '-256'); - } else { - Logging::error("The file $file does not exist"); - } } else { $get_img = ''; } @@ -231,26 +219,120 @@ class FileDataHelper { } else { $ext = 'jpg'; } + self::resizeGroup($rfile, $ext); - if (file_exists($rfile)) { - self::resizeImage($rfile, $rfile . '-32.jpg', $ext, 32, 100); - self::resizeImage($rfile, $rfile . '-64.jpg', $ext, 64, 100); - self::resizeImage($rfile, $rfile . '-128.jpg', $ext, 128, 100); - self::resizeImage($rfile, $rfile . '-256.jpg', $ext, 256, 100); - self::resizeImage($rfile, $rfile . '-512.jpg', $ext, 512, 100); - self::imgToDataURI($rfile . '-32.jpg', $rfile . '-32'); - self::imgToDataURI($rfile . '-64.jpg', $rfile . '-64'); - self::imgToDataURI($rfile . '-128.jpg', $rfile . '-128'); - self::imgToDataURI($rfile . '-256.jpg', $rfile . '-256'); - } else { - Logging::error("The file $rfile does not exist"); - } } else { $get_img = ""; } return $get_img; } + /** + * Upload artwork + * + * @param string $trackid + * @param string $data + * + * @return string Path to artwork + */ + public static function setArtwork($trackid, $data) + { + $file = Application_Model_StoredFile::RecallById($trackid); + $md = $file->getMetadata(); + + $storDir = Application_Model_MusicDir::getStorDir(); + $fp = $storDir->getDirectory(); + + $dbAudioPath = $md["MDATA_KEY_FILEPATH"]; + $fullpath = $fp . $dbAudioPath; + + if ($data == "0") { + + $get_img = ""; + self::removeArtwork($trackid, $data); + + } else { + + $base64 = @$data; + $mime = explode(';', $base64)[0]; + + $audioPath = dirname($fullpath); + $dbPath = dirname($dbAudioPath); + $path_parts = pathinfo($fullpath); + $file = $path_parts['filename']; + + //Save Data URI + if (file_put_contents($audioPath . "/" . $file, $base64)) { + $get_img = $dbPath . "/" . $file; + } else { + Logging::error("Could not save Data URI"); + } + + $rfile = $audioPath . "/" . $file; + + if ($mime == "data:image/png") { + $ext = 'png'; + } elseif ($mime == "data:image/gif") { + $ext = 'gif'; + } elseif ($mime == "data:image/bmp") { + $ext = 'bmp'; + } else { + $ext = 'jpg'; + } + self::resizeGroup($rfile, $ext); + + } + return $get_img; + } + + /** + * + * Deletes just the artwork + */ + public static function removeArtwork($trackid) + { + $file = Application_Model_StoredFile::RecallById($trackid); + $md = $file->getMetadata(); + + $storDir = Application_Model_MusicDir::getStorDir(); + $fp = $storDir->getDirectory(); + + $dbAudioPath = $md["MDATA_KEY_ARTWORK"]; + $fullpath = $fp . $dbAudioPath; + + if (file_exists($fullpath)) { + foreach (glob("$fullpath*", GLOB_NOSORT) as $filename) { + unlink($filename); + } + } else { + throw new Exception("Could not locate file ".$filepath); + } + return ""; + } + + /** + * Resize artwork group + * + * @param string $file + * @param string $ext + */ + public static function resizeGroup($file, $ext) + { + if (file_exists($file)) { + self::resizeImage($file, $file . '-32.jpg', $ext, 32, 100); + self::resizeImage($file, $file . '-64.jpg', $ext, 64, 100); + self::resizeImage($file, $file . '-128.jpg', $ext, 128, 100); + self::resizeImage($file, $file . '-256.jpg', $ext, 256, 100); + self::resizeImage($file, $file . '-512.jpg', $ext, 512, 100); + self::imgToDataURI($file . '-32.jpg', $file . '-32'); + self::imgToDataURI($file . '-64.jpg', $file . '-64'); + self::imgToDataURI($file . '-128.jpg', $file . '-128'); + self::imgToDataURI($file . '-256.jpg', $file . '-256'); + } else { + Logging::error("The file $file does not exist"); + } + } + /** * Render image * Used in API to render JPEG diff --git a/airtime_mvc/application/controllers/LibraryController.php b/airtime_mvc/application/controllers/LibraryController.php index 28c5f02bb..7b41603f7 100644 --- a/airtime_mvc/application/controllers/LibraryController.php +++ b/airtime_mvc/application/controllers/LibraryController.php @@ -395,7 +395,16 @@ class LibraryController extends Zend_Controller_Action //on edit, if no artwork is set and audiofile has image, automatically add it if ($j["name"] == "artwork") { if ($j["value"] == null || $j["value"] == ''){ - $serialized["artwork"] = FileDataHelper::resetArtwork($file_id); + $serialized["artwork"] = FileDataHelper::resetArtwork($file_id); + } + } elseif ($j["name"] == "set_artwork") { + if ($j["value"] != null || $j["value"] != ''){ + $serialized["artwork"] = FileDataHelper::setArtwork($file_id, $j["value"] ); + } + } elseif ($j["name"] == "remove_artwork") { + if ($j["value"] == 1){ + $remove_artwork = true; + $serialized["artwork"] = FileDataHelper::removeArtwork($file_id); } } else { $serialized[$j["name"]] = $j["value"]; diff --git a/airtime_mvc/application/forms/EditAudioMD.php b/airtime_mvc/application/forms/EditAudioMD.php index 62a83f66c..dda96aa5c 100644 --- a/airtime_mvc/application/forms/EditAudioMD.php +++ b/airtime_mvc/application/forms/EditAudioMD.php @@ -20,15 +20,32 @@ class Application_Form_EditAudioMD extends Zend_Form // Add artwork hidden field $artwork = new Zend_Form_Element_Hidden('artwork'); + $artwork->class = 'input_text artwork_'. $p_id; $artwork->setFilters(array('StringTrim')) ->setValidators(array( - new Zend_Validate_StringLength(array('max' => 512)) + new Zend_Validate_StringLength(array('max' => 2048)) )); $file_id->addDecorator('HtmlTag', array('tag' => 'div', 'style' => 'display:none')); $file_id->removeDecorator('Label'); $file_id->setAttrib('class', 'artwork'); $this->addElement($artwork); + // Set artwork hidden field + $set_artwork = new Zend_Form_Element_Hidden('set_artwork'); + $set_artwork->class = 'input_text set_artwork_'. $p_id; + $file_id->addDecorator('HtmlTag', array('tag' => 'div', 'style' => 'display:none')); + $file_id->removeDecorator('Label'); + $file_id->setAttrib('class', 'set_artwork'); + $this->addElement($set_artwork); + + // Remove artwork hidden field + $remove_artwork = new Zend_Form_Element_Hidden('remove_artwork'); + $remove_artwork->class = 'input_text remove_artwork_'. $p_id; + $file_id->addDecorator('HtmlTag', array('tag' => 'div', 'style' => 'display:none')); + $file_id->removeDecorator('Label'); + $file_id->setAttrib('class', 'remove_artwork'); + $this->addElement($remove_artwork); + // Add title field $track_title = new Zend_Form_Element_Text('track_title'); $track_title->class = 'input_text'; diff --git a/airtime_mvc/application/models/Schedule.php b/airtime_mvc/application/models/Schedule.php index 333dbfb4b..3a1be171c 100644 --- a/airtime_mvc/application/models/Schedule.php +++ b/airtime_mvc/application/models/Schedule.php @@ -104,7 +104,10 @@ SQL; $utcNow = new DateTime("now", new DateTimeZone("UTC")); $shows = Application_Model_Show::getPrevCurrentNext($utcNow, $utcTimeEnd, $showsToRetrieve); - $currentShowID = (is_array($shows['currentShow'] && count($shows['currentShow'])>0))?$shows['currentShow']['instance_id']:null; + $currentShowID = null; + if (is_array($shows['currentShow']) && count($shows['currentShow'])>0) { + $currentShowID = $shows['currentShow']['instance_id']; + } $source = self::_getSource(); $results = Application_Model_Schedule::getPreviousCurrentNextMedia($utcNow, $currentShowID, self::_getSource()); diff --git a/airtime_mvc/application/views/scripts/library/edit-file-md.phtml b/airtime_mvc/application/views/scripts/library/edit-file-md.phtml index 4f09f9db4..c216987ae 100644 --- a/airtime_mvc/application/views/scripts/library/edit-file-md.phtml +++ b/airtime_mvc/application/views/scripts/library/edit-file-md.phtml @@ -19,7 +19,19 @@
- '; ?> +
+
+ + +
+
+
+
+
+
+
+ Remove +
diff --git a/airtime_mvc/build/sql/defaultdata.sql b/airtime_mvc/build/sql/defaultdata.sql index 7712e23f2..cd976b11f 100644 --- a/airtime_mvc/build/sql/defaultdata.sql +++ b/airtime_mvc/build/sql/defaultdata.sql @@ -35,7 +35,7 @@ INSERT INTO cc_stream_setting ("keyname", "value", "type") VALUES ('s1_pass', 'h INSERT INTO cc_stream_setting ("keyname", "value", "type") VALUES ('s1_admin_user', '', 'string'); INSERT INTO cc_stream_setting ("keyname", "value", "type") VALUES ('s1_admin_pass', '', 'string'); INSERT INTO cc_stream_setting ("keyname", "value", "type") VALUES ('s1_mount', 'airtime_128', 'string'); -INSERT INTO cc_stream_setting ("keyname", "value", "type") VALUES ('s1_url', 'http://airtime.sourcefabric.org', 'string'); +INSERT INTO cc_stream_setting ("keyname", "value", "type") VALUES ('s1_url', 'https://libretime.org', 'string'); INSERT INTO cc_stream_setting ("keyname", "value", "type") VALUES ('s1_description', 'LibreTime Radio! Stream #1', 'string'); INSERT INTO cc_stream_setting ("keyname", "value", "type") VALUES ('s1_genre', 'genre', 'string'); @@ -392,4 +392,4 @@ INSERT INTO cc_pref("keystr", "valstr") VALUES('whats_new_dialog_viewed', 1); --added for LibreTime to turn on podcast album override by default 3.0.0.alpha6 INSERT INTO cc_pref("keystr", "valstr") VALUES('podcast_album_override', 1); INSERT INTO cc_pref("keystr", "valstr") VALUES('podcast_auto_smartblock', 0); --- end \ No newline at end of file +-- end diff --git a/airtime_mvc/public/css/images/no-cover.jpg b/airtime_mvc/public/css/images/no-cover.jpg index cd38cfc0d..bd4f12cf8 100644 Binary files a/airtime_mvc/public/css/images/no-cover.jpg and b/airtime_mvc/public/css/images/no-cover.jpg differ diff --git a/airtime_mvc/public/css/styles.css b/airtime_mvc/public/css/styles.css index 981ac5f92..087bb5ba9 100644 --- a/airtime_mvc/public/css/styles.css +++ b/airtime_mvc/public/css/styles.css @@ -4284,3 +4284,64 @@ li .ui-state-hover { .track-edit-right { margin-left: 160px; } + +/* Artwork Upload in Tracks Edit */ +.artwork-upload { + position: relative; + max-width: 205px; +} + +.artwork-upload .artwork-edit { + position: absolute; + z-index: 1; + top: 0; +} +.artwork-upload .artwork-edit input { + display: none; +} +.artwork-upload .artwork-edit input + label { + display: inline-block; + width: 140px; + height: 140px; + margin: 1px; + border-radius: 1px; + border: 1px solid transparent; + box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.12); + cursor: pointer; + font-weight: normal; + transition: all 0.2s ease-in-out; +} +.artwork-upload .artwork-edit input + label:hover { + background: rgba(0, 0, 0, 0.40); + border-color: #d6d6d6; +} +.artwork-upload .artwork-edit input + label:after { + color: #757575; + position: absolute; + top: 10px; + left: 0; + right: 0; + text-align: center; + margin: auto; +} +.artwork-upload .artwork-preview { + width: 140px; + height: 140px; + position: relative; + border-radius: 1px; + border: 2px solid #F8F8F8; + box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.1); +} +.artwork-upload .artwork-preview > div { + width: 100%; + height: 100%; + background-size: cover; + background-repeat: no-repeat; + background-position: center; +} + +/* Drag and Drop Artwork */ +body.droppable .artwork-upload .artwork-preview { + border: 2px dashed lightblue; + z-index: 9999; +} diff --git a/airtime_mvc/public/js/airtime/library/library.js b/airtime_mvc/public/js/airtime/library/library.js index 38a675e81..d4a28373d 100644 --- a/airtime_mvc/public/js/airtime/library/library.js +++ b/airtime_mvc/public/js/airtime/library/library.js @@ -1601,6 +1601,70 @@ var validationTypes = { }; +function readArtworkURL(input, id) { + + if (input.files && input.files[0]) { + var reader = new FileReader(); + reader.onload = function(e) { + $('.artwork-preview-'+id).css('background-image', 'url('+e.target.result +')'); + $('.artwork-preview-'+id).hide(); + $('.artwork-preview-'+id).fadeIn(500); + $('.set_artwork_'+id).val(function() { + return e.target.result; + }); + } + reader.readAsDataURL(input.files[0]); + } +} + +// Resample Artwork +var resampleImg = (function (canvas) { + + function resampleImg(img, width, height, onresample) { + var load = typeof img == "string", + i = load || img; + if (load) { + i = new Image; + i.onload = onload; + i.onerror = onerror; + } + i._onresample = onresample; + i._width = width; + i._height = height; + load ? (i.src = img) : onload.call(img); + } + + function onerror() { + throw ("not found: " + this.src); + } + + function onload() { + var img = this, + width = img._width, + height = img._height, + onresample = img._onresample; + + var minValue = Math.min(img.height, img.width); + width == null && (width = round(img.width * height / img.height)); + height == null && (height = round(img.height * width / img.width)); + + delete img._onresample; + delete img._width; + delete img._height; + canvas.width = width; + canvas.height = height; + context.drawImage(img,0,0,minValue,minValue,0,0,width,height); + onresample(canvas.toDataURL("image/jpeg")); + } + + var context = canvas.getContext("2d"), + round = Math.round; + + return resampleImg; + +}(this.document.createElement("canvas"))); + + $(document).ready(function() { if (window.location.href.indexOf("showbuilder") > -1) { AIRTIME.library.initPodcastDatatable(); @@ -1613,5 +1677,114 @@ $(document).ready(function() { $(window).resize(function() { resizeAdvancedSearch(); }); -}); + // delete artwork + $(document).on('click', '.delete-artwork', function(event) { + event.preventDefault(); + event.stopPropagation(); + var id = $(this).attr('data-id'); + $('.artwork-preview-'+id).css('background-image', 'url('+ baseUrl +'css/images/no-cover.jpg)'); + $('.artwork-preview-'+id).hide(); + $('.artwork-preview-'+id).fadeIn(500); + $('.artwork_'+id).val(function() { + return ""; + }); + $('.set_artwork_'+id).val(function() { + return ""; + }); + $('.remove_artwork_'+id).val(function() { + return 1; + }); + }); + + // image upload by clicking on the artwork container + $(document).on('change', '.artworkUpload', 'input', function(event) { + event.preventDefault(); + event.stopPropagation(); + var id = $(this).attr('data-id'); + readArtworkURL(this, id); + }); + + // image upload by dragging onto the artwork container + $.event.props.push('dataTransfer'); + (function() { + + var s; + var Artwork = { + settings: { + body: $("body") + }, + init: function() { + s = Artwork.settings; + Artwork.bindUIActions(); + }, + bindUIActions: function() { + + var timer; + s.body.on('dragover', '.artwork-upload', function(event) { + + event.preventDefault(); + event.stopPropagation(); + clearTimeout(timer); + Artwork.showDroppableArea(); + return false; + }); + s.body.on('dragleave', '.artwork-upload', function(event) { + + event.preventDefault(); + event.stopPropagation(); + timer = setTimeout(function() { + Artwork.hideDroppableArea(); + }, 200); + }); + s.body.on('drop', '.artwork-upload', function(event) { + event.preventDefault(); + event.stopPropagation(); + var id = $(this).attr('data-id'); + Artwork.handleDrop(event.dataTransfer.files, id); + }); + + }, + showDroppableArea: function() { + s.body.addClass("droppable"); + }, + hideDroppableArea: function() { + s.body.removeClass("droppable"); + }, + handleDrop: function(files, id) { + Artwork.hideDroppableArea(); + var file = files[0]; + if (typeof file !== 'undefined' && file.type.match('image.*')) { + Artwork.resizeImage(file, 512, function(data) { + Artwork.placeImage(data, id); + }); + } else { + alert("The file is not an image."); + } + }, + resizeImage: function(file, size, callback) { + var fileTracker = new FileReader; + fileTracker.onload = function() { + resampleImg(this.result, size, size, callback); + } + fileTracker.readAsDataURL(file); + fileTracker.onabort = function() { + alert("Upload aborted!"); + } + fileTracker.onerror = function() { + alert("File could not be read."); + } + }, + placeImage: function(data, id) { + $('.artwork-preview-'+id).css('background-image', 'url('+ data +')'); + $('.artwork-preview-'+id).hide(); + $('.artwork-preview-'+id).fadeIn(500); + $('.set_artwork_'+id).val(function() { + return data; + }); + } + } + Artwork.init(); + })(); + +}); diff --git a/docs/install.md b/docs/install.md index 494bebe56..201334b6d 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,15 +1,18 @@ # Installing LibreTime +There two methods of installing LibreTime - [Source](#source) or +[Ubuntu package](#ubuntu-package). +## Source LibreTime releases can be downloaded [here](https://github.com/LibreTime/libretime/releases). Recommendations: -- LibreTime should generally be installed on a dedicated host running Ubuntu 16.04 LTS (Xenial Xerus). -- LibreTime is undergoing active development, and is currently in ALPHA. -- It is not recommended that you install LibreTime on the same computer you are using as a desktop. +- LibreTime should generally be installed on a dedicated host running Ubuntu 16.04 LTS (Xenial Xerus). +- LibreTime is undergoing active development, and is currently in ALPHA. +- It is not recommended that you install LibreTime on the same computer you are using as a desktop. - Please review the release notes of the version you are planning on installing. -Once you have downloaded and extracted LibreTime, run the installation script by navigating into the +Once you have downloaded and extracted LibreTime, run the installation script by navigating into the folder containing the LibreTime codebase, and run it's install script from the command line: ``` @@ -18,12 +21,27 @@ sudo ./install By default, the installer will install and configure all dependencies. +## Ubuntu Package +LibreTime maintains amd64 .deb packages for Ubuntu 16.04 (Xenial) and 18.04 +(Bionic). These can be downloaded [here](https://github.com/LibreTime/libretime-debian-packaging/releases). +Issues with installation of these packages should be reported to the +[LibretTime/libretime-debian-packaging](https://github.com/LibreTime/libretime-debian-packaging) +repository. + +These are installed by running the following from the command line (the `./` in +front of the libretime package is important): + +``` +sudo apt install icecast2 ./libretime__amd64.deb +``` +`` is replaced by the version of the package downloaded. + ## Alternative OS installations -Installation in Debian 9 and other Linux distributions is possible, but multiple outstanding issues have yet -to be resolved. Installation on Ubuntu 14.04.5 LTS (Trusty Tahr) is also working, but deprecated due to the +Installation in Debian 9 and other Linux distributions is possible, but these +are less tested. Installation on Ubuntu 14.04.5 LTS (Trusty Tahr) is also working, but deprecated due to the fact that this version will reach its official end of life in April 2019. -Plans are in the works for `.deb` and `.rpm` packages, as well as Docker and AWS images. +Plans are in the works for `.rpm` packages, as well as Docker and AWS images. Please note that the install script does not take care to ensure that any packages installed are set up in a secure manner. Please see the chapter on diff --git a/python_apps/pypo/liquidsoap/liquidsoap.cfg b/python_apps/pypo/liquidsoap/liquidsoap.cfg index bb6c6d88f..ae4bbd203 100644 --- a/python_apps/pypo/liquidsoap/liquidsoap.cfg +++ b/python_apps/pypo/liquidsoap/liquidsoap.cfg @@ -60,14 +60,14 @@ s3_mount = "airtime_160.mp3" s4_mount = "airtime_160.mp3" # Webstream metadata settings -s1_url = "http://airtime.sourcefabric.org" -s2_url = "http://airtime.sourcefabric.org" -s3_url = "http://airtime.sourcefabric.org" -s4_url = "http://airtime.sourcefabric.org" -s1_description = "Airtime Radio! Stream 1" -s2_description = "Airtime Radio! Stream 2" -s3_description = "Airtime Radio! Stream 3" -s4_description = "Airtime Radio! Stream 4" +s1_url = "https://libretime.org" +s2_url = "https://libretime.org" +s3_url = "https://libretime.org" +s4_url = "https://libretime.org" +s1_description = "LibreTime Radio! Stream 1" +s2_description = "LibreTime Radio! Stream 2" +s3_description = "LibreTime Radio! Stream 3" +s4_description = "LibreTime Radio! Stream 4" s1_genre = "genre" s2_genre = "genre" s3_genre = "genre" diff --git a/python_apps/pypo/pypo/pypomessagehandler.py b/python_apps/pypo/pypo/pypomessagehandler.py index 61caab976..6e31d2f70 100644 --- a/python_apps/pypo/pypo/pypomessagehandler.py +++ b/python_apps/pypo/pypo/pypomessagehandler.py @@ -7,15 +7,32 @@ import sys from threading import Thread import time # For RabbitMQ -from kombu.connection import BrokerConnection +from kombu.connection import Connection from kombu.messaging import Exchange, Queue from kombu.simple import SimpleQueue from amqp.exceptions import AMQPError import json +from kombu.mixins import ConsumerMixin + logging.captureWarnings(True) +class RabbitConsumer(ConsumerMixin): + def __init__(self, connection, queues, handler): + self.connection = connection + self.queues = queues + self.handler = handler + + def get_consumers(self, Consumer, channel): + return [ + Consumer(self.queues, callbacks=[self.on_message], accept=['text/plain']), + ] + + def on_message(self, body, message): + self.handler.handle_message(message.payload) + message.ack() + class PypoMessageHandler(Thread): def __init__(self, pq, rq, config): Thread.__init__(self) @@ -26,22 +43,19 @@ class PypoMessageHandler(Thread): def init_rabbit_mq(self): self.logger.info("Initializing RabbitMQ stuff") - simple_queue = None try: schedule_exchange = Exchange("airtime-pypo", "direct", durable=True, auto_delete=True) schedule_queue = Queue("pypo-fetch", exchange=schedule_exchange, key="foo") - connection = BrokerConnection(self.config["host"], - self.config["user"], - self.config["password"], - self.config["vhost"]) - - channel = connection.channel() - simple_queue = SimpleQueue(channel, schedule_queue) + with Connection(self.config["host"], \ + self.config["user"], \ + self.config["password"], \ + self.config["vhost"], \ + heartbeat = 5) as connection: + rabbit = RabbitConsumer(connection, [schedule_queue], self) + rabbit.run() except Exception, e: self.logger.error(e) - return simple_queue - """ Handle a message from RabbitMQ, put it into our yucky global var. Hopefully there is a better way to do this. @@ -89,12 +103,7 @@ class PypoMessageHandler(Thread): def main(self): try: - with self.init_rabbit_mq() as queue: - while True: - message = queue.get(block=True) - self.handle_message(message.payload) - # ACK the message to take it off the queue - message.ack() + self.init_rabbit_mq() except Exception, e: self.logger.error('Exception: %s', e) self.logger.error("traceback: %s", traceback.format_exc())