Merged tag overhamj-2.1.0 into default
M .classpath +7 -2
@@ 6,9 6,9 @@ 
 			<attribute name="maven.pomderived" value="true"/>
 		</attributes>
 	</classpathentry>
-	<classpathentry kind="src" path="test"/>
-	<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.8">
+	<classpathentry kind="src" output="target/test-classes" path="test">
 		<attributes>
+			<attribute name="optional" value="true"/>
 			<attribute name="maven.pomderived" value="true"/>
 		</attributes>
 	</classpathentry>

          
@@ 36,5 36,10 @@ 
 			<attribute name="maven.pomderived" value="true"/>
 		</attributes>
 	</classpathentry>
+	<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.8">
+		<attributes>
+			<attribute name="maven.pomderived" value="true"/>
+		</attributes>
+	</classpathentry>
 	<classpathentry kind="output" path="target/classes"/>
 </classpath>

          
M .hgtags +1 -0
@@ 4,3 4,4 @@ 9885fdb13b8b74ea1322b76d8d0d5cd9a6dc9f3e
 c0c24679da5d95c2acaa16834f992fda3f7309b7 overhamj-2.0.2
 5599cb9be9c7095f8cbe8198d24afc35d82b3df9 overhamj-2.0.3
 e74846bca1f47b75378c693964d6ccce4ec232a2 overhamj-2.0.4
+5285b3f9453ecb72805267854e511e26814e5fb4 overhamj-2.1.0

          
A => .settings/org.eclipse.jdt.core.prefs +12 -0
@@ 0,0 1,12 @@ 
+eclipse.preferences.version=1
+org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled
+org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8
+org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve
+org.eclipse.jdt.core.compiler.compliance=1.8
+org.eclipse.jdt.core.compiler.debug.lineNumber=generate
+org.eclipse.jdt.core.compiler.debug.localVariable=generate
+org.eclipse.jdt.core.compiler.debug.sourceFile=generate
+org.eclipse.jdt.core.compiler.problem.assertIdentifier=error
+org.eclipse.jdt.core.compiler.problem.enumIdentifier=error
+org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
+org.eclipse.jdt.core.compiler.source=1.8

          
A => .settings/org.eclipse.m2e.core.prefs +4 -0
@@ 0,0 1,4 @@ 
+activeProfiles=
+eclipse.preferences.version=1
+resolveWorkspaceProjects=true
+version=1

          
M debian/changelog +11 -0
@@ 1,3 1,14 @@ 
+overhamj (2.1.0) stable; urgency=low
+
+  * 294b356fd859: Merged in f/201705-qcode-dump (pull request #16)
+  * 45eaef934a97: Merged in i/1 (pull request #15)
+  * ebe908d30018: Merged in f/201703-index-examples (pull request #18)
+  * 599b3eb0d22d: Merged in f/201703-module-system-ping (pull request #17)
+  * 8d3c44064f57: Merged in f/201709-module-recordings (pull request #19)
+  * 8fbe7be50076: Merged branch f/201709-module-recordings at revision ccba68457ef3 into develop
+
+ -- Duncan Ross Palmer <palmer@overchat.org>  Fri, 29 Sep 2017 20:10:28 +0100
+
 overhamj (2.0.4) stable; urgency=low
 
   * Added documented but missing system/ping feature

          
M pom.xml +27 -4
@@ 4,7 4,7 @@ 
 	<groupId>org.overchat.overham</groupId>
 	<artifactId>overhamj</artifactId>
 	<packaging>jar</packaging>
-	<version>2.0.4</version>
+	<version>2.1.0</version>
 	<name>overhamj</name>
 	<url>http://maven.apache.org</url>
 	<dependencies>

          
@@ 24,6 24,18 @@ 
 			<artifactId>spring-context</artifactId>
 			<version>4.3.6.RELEASE</version>
 		</dependency>
+		<!-- https://mvnrepository.com/artifact/com.amazonaws/aws-java-sdk-s3 -->
+		<dependency>
+			<groupId>com.amazonaws</groupId>
+			<artifactId>aws-java-sdk-s3</artifactId>
+			<version>1.11.192</version>
+		</dependency>
+		<!-- https://mvnrepository.com/artifact/org.ini4j/ini4j -->
+		<dependency>
+			<groupId>org.ini4j</groupId>
+			<artifactId>ini4j</artifactId>
+			<version>0.5.1</version>
+		</dependency>
 		<!-- https://mvnrepository.com/artifact/commons-io/commons-io -->
 		<dependency>
 			<groupId>commons-io</groupId>

          
@@ 64,6 76,12 @@ 
 			<artifactId>guava</artifactId>
 			<version>21.0</version>
 		</dependency>
+		<!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java -->
+		<dependency>
+			<groupId>mysql</groupId>
+			<artifactId>mysql-connector-java</artifactId>
+			<version>5.1.6</version>
+		</dependency>
 	</dependencies>
 	<build>
 		<sourceDirectory>src</sourceDirectory>

          
@@ 71,6 89,11 @@ 
 		<plugins>
 			<plugin>
 				<groupId>org.apache.maven.plugins</groupId>
+				<artifactId>maven-resources-plugin</artifactId>
+				<version>3.0.2</version>
+			</plugin>
+			<plugin>
+				<groupId>org.apache.maven.plugins</groupId>
 				<artifactId>maven-compiler-plugin</artifactId>
 				<version>3.6.1</version>
 				<configuration>

          
@@ 105,12 128,12 @@ 
 				</executions>
 			</plugin>
 			<plugin>
-				<groupId>org.overchat</groupId>
+				<groupId>net.sf.debian-maven</groupId>
 				<artifactId>debian-maven-plugin</artifactId>
-				<version>2.0.4</version>
+				<version>2.1.0</version>
 				<configuration>
 					<options>
-						<version>2.0.4</version>
+						<version>2.1.0</version>
 						<maintainer>${env.DEBFULLNAME} &lt;${env.DEBEMAIL}&gt;</maintainer>
 						<homepage>https://www.facebook.com/groups/overham/</homepage>
 						<description>Amateur Radio Library</description>

          
A => src/org/overchat/overham/HTTPStatus.java +21 -0
@@ 0,0 1,21 @@ 
+package org.overchat.overham;
+
+import org.overchat.overham.module.ExitCodes;
+
+public class HTTPStatus {
+
+	public HTTPStatus(final int code, final ExitCodes exitCode) {
+		this.code = code;
+		this.exitCode = exitCode;
+	}
+
+	/** HTTP error status */
+	public final int code;
+
+	/** Message sent with code, visible in some browsers */
+	public final ExitCodes exitCode;
+
+	public String formatMessage() {
+		return String.format("%s:%d:\"%s\"", this.exitCode.name(), this.exitCode.ordinal(), this.exitCode.toString());
+	}
+}

          
A => src/org/overchat/overham/Response.java +24 -0
@@ 0,0 1,24 @@ 
+package org.overchat.overham;
+
+public class Response {
+
+	public Response(final Boolean success) {
+		this.success = success;
+		this.what = null;
+	}
+
+	/**
+	 * Note that what only makes sense when success is false.
+	 */
+	public Response(final Boolean success, final String what) {
+		this.success = success;
+		this.what = what;
+
+		if (success && what != null) {
+			throw new RuntimeException("Response what should not be unsed in the case of success");
+		}
+	}
+
+	public final Boolean success;
+	public final String what;
+}

          
M src/org/overchat/overham/Server.java +69 -24
@@ 46,19 46,21 @@ import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 import org.apache.commons.lang.StringUtils;
+import org.overchat.overham.config.Configuration;
 import org.overchat.overham.db.HamDatabase;
+import org.overchat.overham.module.CallStyle;
 import org.overchat.overham.module.Loader;
+import org.overchat.overham.module.ReturnData;
 import org.overchat.overham.module.interfaces.Module;
 import org.reflections.Reflections;
 import org.reflections.scanners.ResourcesScanner;
 
+import com.amazonaws.auth.profile.ProfileCredentialsProvider;
+import com.amazonaws.services.s3.AmazonS3;
+import com.amazonaws.services.s3.AmazonS3Client;
 import com.google.common.io.Files;
 import com.google.common.io.Resources;
 
-import spark.Request;
-import spark.Response;
-import spark.Route;
-
 /**
  * @author duncan.palmer
  *

          
@@ 68,16 70,17 @@ public class Server {
 	Loader classLoader;
 	final private long startTime;
 	private HamDatabase hamDatabase;
+	private Configuration config;
+	private AmazonS3 s3Client;
 
-	/**
-	 * 
-	 */
+	@SuppressWarnings("deprecation")
 	public Server() {
-		// TODO Auto-generated constructor stub
 		this.classLoader = new Loader(classLoader);
 		Thread.currentThread().setContextClassLoader(classLoader);
 		this.startTime = java.lang.System.currentTimeMillis();
-		this.hamDatabase = new HamDatabase();
+		this.config = new Configuration();
+		this.s3Client = new AmazonS3Client(new ProfileCredentialsProvider("src/main/resources/overhamj.ini", "s3"));
+		this.hamDatabase = new HamDatabase(config);
 	}
 
 	public Module moduleLookup(final String name) {

          
@@ 126,6 129,10 @@ public class Server {
 		return this.hamDatabase;
 	}
 
+	public AmazonS3 getS3Client() {
+		return this.s3Client;
+	}
+
 	public List<String> modules() {
 		List<String> modules = new ArrayList<String>();
 		final String testClassPatternString = "Test\\.class$";

          
@@ 157,7 164,8 @@ public class Server {
 		}
 
 		java.lang.System.out.println(String.format(
-			"There are %d modules in the system", modules.size()
+			"There are %d modules in the system",
+			modules.size()
 		));
 		return modules;
 	}

          
@@ 171,6 179,7 @@ public class Server {
 		return sb.toString();
 	}
 
+	@SuppressWarnings("deprecation")
 	public void init() {
 		List<String> modules = this.modules();
 		for (final String moduleName : modules) { // First letter is uppercase

          
@@ 179,23 188,59 @@ public class Server {
 			List<String> methods = module.calls();
 			for (final String methodName : methods) {
 				String routePath = buildRoutePath(moduleName, methodName);
-				java.lang.System.out.println("Created route path: " + routePath);
-				get(
-					routePath,
-					"application/json",
-					new Route() {
-						@Override
-						public Object handle(Request request, Response response) {
-							Map<String, String> requestParams = new HashMap<String, String>();
-							Set<String> argNames = request.queryParams();
-							for (String argName : argNames) {
-								requestParams.put(argName, request.queryParams(argName));
+				final String methodMimeType = module.getUserMethodMimeType(methodName);
+				java.lang.System.out.println("Created route path: " + routePath + " (" + methodMimeType + ")");
+
+				get(routePath, (req, res) -> { // This Lambda requires Java 1.8
+					Map<String, String> requestParams = new HashMap<String, String>();
+					Set<String> argNames = req.queryParams();
+					for (String argName : argNames) {
+						requestParams.put(argName, req.queryParams(argName));
+					}
+
+					res.raw().setCharacterEncoding("utf-8");
+					res.raw().addHeader("Content-Type", methodMimeType);
+
+					if (CallStyle.LEGACY == moduleLookup(moduleName).getUserMethodCallStyle(methodName)) {
+						return moduleLookup(moduleName).callAndDecode(methodName, requestParams);
+					} else { // New style ReturnData (advanced processing)
+						int singleByte;
+						int byteCount = 0;
+
+						ReturnData returnData = moduleLookup(moduleName).callForData(methodName, requestParams);
+						if (null == returnData) {
+							java.lang.System.out.println("FIXME: Sack off returning null");
+							return null;
+						}
+
+						res.raw().setStatus(returnData.httpStatus.code, returnData.httpStatus.formatMessage());
+						if (returnData.fileName != null) {
+							res.raw().addHeader("Content-Disposition", "attachment; filename=\"" + returnData.fileName + "\"");
+							res.raw().addHeader("Accept-Ranges", "none");
+							res.raw().addHeader("Cache-Control", "no-cache");
+							res.raw().addHeader("MIME-Version", "1.0");
+							res.raw().addHeader("Content-Transfer-Encoding", "binary");
+						}
+
+						if (returnData.inputStream != null) {
+							while ((singleByte = returnData.inputStream.read()) > -1) {
+								res.raw().getOutputStream().write(singleByte);
+								byteCount++;
 							}
-							//
-							return moduleLookup(moduleName).callAndDecode(methodName, requestParams);
+							res.raw().setContentLength(byteCount);
+							returnData.inputStream.close();
+
+							res.raw().flushBuffer();
+							res.raw().getOutputStream().close();
+						}
+
+						if (returnData.jsonOutput == null) {
+							return res.raw();
+						} else {
+							return returnData.jsonOutput;
 						}
 					}
-				);
+				});
 			}
 		}
 	}

          
A => src/org/overchat/overham/config/Configuration.java +63 -0
@@ 0,0 1,63 @@ 
+package org.overchat.overham.config;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.ini4j.Ini;
+import org.ini4j.InvalidFileFormatException;
+import org.ini4j.Profile.Section;
+
+public class Configuration {
+
+	private Ini config;
+	private static final String CONF_FILE = "/overhamj.ini";
+
+	public Configuration() {
+		readConfig();
+	}
+
+	private InputStream getconfigInputStream(final String fileName) {
+		java.lang.System.out.println("Opening resource " + fileName);
+		return getClass().getResourceAsStream(fileName);
+	}
+
+	protected void readConfig() throws ConfigurationException {
+		try {
+			String fileError = "";
+			InputStream configFile = getconfigInputStream(CONF_FILE);
+			if (configFile != null) {
+				config = new Ini(configFile);
+				if (config.isEmpty()) {
+					fileError = "empty";
+				}
+				configFile.close();
+			} else {
+				fileError = "not found";
+			}
+			if (!fileError.isEmpty()) {
+				throw new ConfigurationException("Config file " + CONF_FILE + " " + fileError + "!");
+			}
+		} catch (InvalidFileFormatException e) {
+			throw new ConfigurationException("Config file " + CONF_FILE + " was invalid!", e);
+		} catch (IOException e) {
+			throw new ConfigurationException("IO error reading configuration file \"" + CONF_FILE + "\"", e);
+		}
+	}
+
+	/**
+	 * Retrieve a specific named section from the configuration file.
+	 *
+	 * @param section
+	 *            The name of the section to retrieve.
+	 * @return Will return a ConfigurationSection object. If the section does not exist,
+	 *         a dummy object is return, i.e. this function will never return null.
+	 */
+	public Section getSection(String section) throws ConfigurationException {
+		Section s = this.config.get(section);
+		if (s == null) {
+			throw new ConfigurationException("Section [" + section + "] is missing");
+		}
+
+		return s;
+	}
+}

          
A => src/org/overchat/overham/config/ConfigurationException.java +13 -0
@@ 0,0 1,13 @@ 
+package org.overchat.overham.config;
+
+public class ConfigurationException extends RuntimeException {
+	private static final long serialVersionUID = 1L;
+
+	public ConfigurationException(String msg) {
+		super(msg);
+	}
+
+	public ConfigurationException(String msg, Exception cause) {
+		super(msg, cause);
+	}
+}

          
M src/org/overchat/overham/db/HamDatabase.java +109 -46
@@ 40,12 40,16 @@ import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.util.List;
 
+import org.ini4j.Profile.Section;
+import org.overchat.overham.config.Configuration;
+
 /**
- * @author duncan.palmer
  * The HamDatabase is a shared module, accessible to modules, via the Server,
  * for resolving static data, which does not change at run-time, such as QCode
  * meanings or country information.  In the future, we may provide a databases
  * package, and provide other types of database, such as cached information from QRZ.com
+ *
+ * @author duncan.palmer
  */
 public class HamDatabase {
 

          
@@ 53,83 57,142 @@ public class HamDatabase {
 	 * Hold a reference to the connection to the database.
 	 * Not for direct use outside of the class.
 	 */
-	private Connection connection = null;
-	private int callCount = 0;
+	private Connection staticConnection = null;
+	private Connection dynamicConnection = null;
+
+	private int staticCallCount = 0;
+	private int dynamicCallCount = 0;
+
+	private Configuration config;
 
-	private Connection connect() throws SQLException, ClassNotFoundException {
-		callCount++;
-		if (this.connection != null && 0 == (callCount % 100)) {
-			this.connection.close();
-			this.connection = null;
+	public HamDatabase(Configuration config) {
+		this.config = config;
+	}
+
+	private Connection connectStatic() throws SQLException, ClassNotFoundException {
+		staticCallCount++;
+		if (this.staticConnection != null && 0 == (staticCallCount % 100)) {
+			this.staticConnection.close();
+			this.staticConnection = null;
 		}
 
 		Class.forName("org.sqlite.JDBC");
-		if (null == this.connection) {
+		if (null == this.staticConnection) {
 			String resourceName;
 			String dbName = "HamDatabase.db";
 			String filePath = "src/main/resources/" + dbName;
 			File varTmpDir = new File(filePath);
 			boolean exists = varTmpDir.exists();
-			
+
 			if (exists) {
 				resourceName = filePath;
 			} else {
-				resourceName = ":resource:" + getClass().getResource("/"+dbName);
+				resourceName = ":resource:" + getClass().getResource("/" + dbName);
 			}
 
-			this.connection = DriverManager.getConnection("jdbc:sqlite:" + resourceName);
+			this.staticConnection = DriverManager.getConnection("jdbc:sqlite:" + resourceName);
+		}
+
+		return this.staticConnection;
+	}
+
+	private Connection connectDynamic() throws SQLException, ClassNotFoundException {
+		dynamicCallCount++;
+		if (this.dynamicConnection != null && 0 == (dynamicCallCount % 100)) {
+			this.dynamicConnection.close();
+			this.dynamicConnection = null;
 		}
 
-		return this.connection;
+		Class.forName("com.mysql.jdbc.Driver");
+		if (null == this.dynamicConnection) {
+			Section section = config.getSection("db");
+
+			this.dynamicConnection = DriverManager.getConnection(String.format(
+				"jdbc:mysql://%s/%s?user=%s&password=%s",
+				section.get("host"),
+				section.get("name"),
+				section.get("user"),
+				section.get("password")
+			));
+		}
+
+		return this.dynamicConnection;
 	}
 
 	/**
 	 * Warning! This is public for now, but direct queries will be
 	 * withdrawn in a later version, meaning you will need to subclass
 	 * TODO: Make protected
+	 *
+	 * @throws InterruptedException
 	 */
-	public ResultSet query(final String query, final List<Object> boundParams)
-			throws SQLException {
+	public ResultSet query(final String query, final List<Object> boundParams, final Boolean dynamic)
+	        throws SQLException, InterruptedException {
 
 		PreparedStatement statement = null;
+		ResultSet resultSet = null;
 
-		try {
-			statement = this.connect().prepareStatement(query);
-		} catch (ClassNotFoundException e) {
-			// TODO: logger
-			java.lang.System.out.println(e);
-			throw new SQLException("Converted ClassNotFoundException to SQLException", e);
-		}
+		do {
+			try {
+				if (statement == null) {
+					if (dynamic) {
+						statement = this.connectDynamic().prepareStatement(query);
+					} else {
+						statement = this.connectStatic().prepareStatement(query);
+					}
+				}
+			} catch (ClassNotFoundException e) {
+				// TODO: logger
+				java.lang.System.out.println(e);
+				throw new SQLException("Converted ClassNotFoundException to SQLException", e);
+			}
 
-		if (boundParams != null) {
-			int paramPos = 0;
-			for (Object boundParam : boundParams) {
-				if (boundParam instanceof String) {
-					statement.setString(++paramPos, (String)boundParam);
-				} else if (boundParam instanceof Integer) {
-					statement.setInt(++paramPos, (Integer)boundParam);
-				} else {
-					throw new SQLException("Must pass String or Integer not " + boundParam.getClass().getSimpleName());
+			if (boundParams != null) {
+				int paramPos = 0;
+				for (Object boundParam : boundParams) {
+					if (boundParam instanceof String) {
+						statement.setString(++paramPos, (String) boundParam);
+					} else if (boundParam instanceof Integer) {
+						statement.setInt(++paramPos, (Integer) boundParam);
+					} else {
+						throw new SQLException("Must pass String or Integer not " + boundParam.getClass().getSimpleName());
+					}
+
+					java.lang.System.out.println(String.format(
+						"Bound %d: %s",
+						paramPos,
+						boundParam
+					));
 				}
+			}
 
-				java.lang.System.out.println(String.format(
-					"Bound %d: %s",
-					paramPos,
-					boundParam
-				));
+			try {
+				resultSet = statement.executeQuery();
+			} catch (Exception e) {
+				this.dynamicConnection = null; // Force reconnection
+				statement = null; // Make the statement under the new connection
+				java.lang.System.out.println(e); // TODO logger
 			}
-		}
 
-		ResultSet resultSet = statement.executeQuery();
+			Thread.sleep(1000);
+		} while (resultSet == null);
+
 		return resultSet;
 	}
 
-	/**
-	 * Warning! This is public for now, but direct queries will be
-	 * withdrawn in a later version, meaning you will need to subclass
-	 * TODO: Make protected
-	 */
-	 public ResultSet query(final String query) throws SQLException {
-	 	return query(query, null);
-	 }
+	public ResultSet query(final String query, final List<Object> boundParams) throws SQLException, InterruptedException {
+		return query(query, boundParams, false);
+	}
+
+	public ResultSet query(final String query) throws SQLException, InterruptedException {
+		return query(query, null, false);
+	}
+
+	public ResultSet queryDynamic(final String query) throws SQLException, InterruptedException {
+		return query(query, null, true);
+	}
+
+	public ResultSet queryDynamic(final String query, final List<Object> boundParams) throws SQLException, InterruptedException {
+		return query(query, boundParams, true);
+	}
 }

          
M src/org/overchat/overham/module/Base.java +66 -4
@@ 41,7 41,9 @@ import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.UUID;
 
+import org.overchat.overham.Response;
 import org.overchat.overham.Server;
 
 import net.sf.json.JSONObject;

          
@@ 61,7 63,17 @@ public abstract class Base {
 	/**
 	 * TODO
 	 */
-	protected Map<String, Integer> userMethodMap; // TODO Integer will one day be function pointer?
+	private Map<String, Integer> userMethodMap; // TODO Integer will one day be function pointer?
+
+	/**
+	 * The userMethodMimeMap describes the MIME type presented to the client
+	 */
+	private Map<String, String> userMethodMimeMap;
+
+	/**
+	 * The userMethodCallStyleMap will be phased out as all modules convert to use org.overchat.overham.module.ReturnData
+	 */
+	private Map<String, CallStyle> userMethodCallStyleMap;
 
 	/**
 	 * Module initialization routine (used instead of a standard constructor).

          
@@ 75,6 87,8 @@ public abstract class Base {
 	public void init(Server server) {
 		this.owner = server;
 		this.userMethodMap = new HashMap<String, Integer>();
+		this.userMethodMimeMap = new HashMap<String, String>();
+		this.userMethodCallStyleMap = new HashMap<String, CallStyle>();
 	}
 
 	public List<String> calls() {

          
@@ 112,14 126,62 @@ public abstract class Base {
 			result = buildErrorForUser(result, ExitCodes.FAILURE);
 		}
 
-		return result.toString();
+		return result.toString(8, 8);
 	}
 
 	/**
 	 * This basic call should be overridden in subclasses
 	 */
 	public JSONObject call(final String methodName, Map<String, String> requestParams) {
-		java.lang.System.out.println("Whoops hit Base call()");
+		java.lang.System.out.println("Whoops hit call() in Base");
+		return null;
+	}
+
+	public ReturnData callForData(final String methodName, Map<String, String> requestParams) {
+		java.lang.System.out.println("Whoops hit callForData() in Base");
 		return null;
 	}
-}
  No newline at end of file
+
+	protected void registerUserMethod(final String methodName, final String mimeType, final CallStyle callStyle) {
+		final String defaultMimeType = "application/json";
+		this.userMethodMap.put(methodName, null);
+		this.userMethodMimeMap.put(methodName, mimeType == null ? defaultMimeType : mimeType);
+		this.userMethodCallStyleMap.put(methodName, callStyle);
+	}
+
+	public String getUserMethodMimeType(final String methodName) {
+		return this.userMethodMimeMap.get(methodName);
+	}
+
+	public CallStyle getUserMethodCallStyle(final String methodName) {
+		CallStyle callStyle = this.userMethodCallStyleMap.get(methodName);
+		java.lang.System.out.println(String.format("getUserMethodCallStyle('%s'): %s", methodName, callStyle.toString()));
+		return callStyle;
+	}
+
+	protected Response checkUnimplemented(final Map<String, String> methodParams, List<String> implementedList) {
+		for (String methodParam : methodParams.keySet()) {
+			if (implementedList.contains(methodParam)) {
+				continue;
+			}
+
+			return new Response(false, methodParam);
+		}
+
+		return new Response(true);
+	}
+
+	protected boolean validateUUID(final String id) {
+		try {
+			UUID.fromString(id);
+		} catch (IllegalArgumentException e) {
+			return false;
+		}
+
+		return true;
+	}
+
+	protected String isoDate(final String dateTime) {
+		return dateTime.substring(0, 19);
+	}
+}

          
A => src/org/overchat/overham/module/CallStyle.java +5 -0
@@ 0,0 1,5 @@ 
+package org.overchat.overham.module;
+
+public enum CallStyle {
+	LEGACY, RETURN_DATA,
+}

          
M src/org/overchat/overham/module/ExitCodes.java +44 -4
@@ 34,7 34,7 @@ 
  * These exit codes are returned on all REST queries back to the client.
  * They are public, documented values, and should not be changed without
  * notice to users.
- * 
+ *
  * @author duncan.palmer
  */
 package org.overchat.overham.module;

          
@@ 124,7 124,7 @@ public enum ExitCodes {
 	 * NOT_FOUND
 	 * Whatever you looked up does not exist.  nb. This is always used for specifics,
 	 * for example, if you look up a QCode, and we don't know it, we use this error.
-	 * It is not used for more fundamental issues, such as using a module which isn't 
+	 * It is not used for more fundamental issues, such as using a module which isn't
 	 * loaded.
 	 */
 	NOT_FOUND { // 6

          
@@ 137,7 137,10 @@ public enum ExitCodes {
 	/**
 	 * MALFORMED_ARGUMENT
 	 * One of the user-supplied arguments is in the wrong format.
-	 * For example, passing a String, where an Integer was expected
+	 * For example, passing a String, where an Integer was expected.
+	 * Sometimes, an argument can be half parsed but the user has made a more obvious
+	 * mistake, such as when a range is specified in reverse, so in that case check
+	 * RANGE is used instead.
 	 */
 	MALFORMED_ARGUMENT { // 7
 		@Override

          
@@ 145,4 148,41 @@ public enum ExitCodes {
 			return "Malformed argument";
 		}
 	},
-};
  No newline at end of file
+
+	/**
+	 * FORBIDDEN_ARTIFACT
+	 * The artifact has been restricted by the administrator,
+	 * for your level of access to the server.
+	 */
+	FORBIDDEN_ARTIFACT { // 8
+		@Override
+		public String toString() {
+			return "Access to this artifact is forbidden";
+		}
+	},
+
+	/**
+	 * TRY_LATER
+	 * The resource you are trying to access is not yet available, but will
+	 * be later.  This is normally the result of a long-running batch process,
+	 * where metadata has become available, but the actual artifact is not yet
+	 * available.
+	 */
+	TRY_LATER { // 9
+		@Override
+		public String toString() {
+			return "This resource is not yet available";
+		}
+	},
+
+	/**
+	 * RANGE
+	 * The range you specified is impossible or out of chronolgical order.
+	 */
+	RANGE { // 10
+		@Override
+		public String toString() {
+			return "Range impossible!";
+		}
+	},
+};

          
A => src/org/overchat/overham/module/ReturnData.java +50 -0
@@ 0,0 1,50 @@ 
+package org.overchat.overham.module;
+
+import java.io.InputStream;
+
+import org.overchat.overham.HTTPStatus;
+
+import net.sf.json.JSONObject;
+
+public class ReturnData {
+
+	public ReturnData(JSONObject jsonOutput, HTTPStatus httpStatus) {
+		this.inputStream = null;
+		this.fileName = null;
+		this.jsonOutput = jsonOutput;
+		this.httpStatus = httpStatus;
+	}
+
+	public ReturnData(JSONObject jsonOutput) {
+		this.inputStream = null;
+		this.fileName = null;
+		this.jsonOutput = jsonOutput;
+		this.httpStatus = new HTTPStatus(200, ExitCodes.SUCCESS);
+	}
+
+	public ReturnData(final InputStream inputStream, final String fileName, HTTPStatus httpStatus) {
+		this.inputStream = inputStream;
+		this.fileName = fileName;
+		this.jsonOutput = null;
+		this.httpStatus = httpStatus;
+	}
+
+	public ReturnData(final InputStream inputStream, final String fileName) {
+		this.inputStream = inputStream;
+		this.fileName = fileName;
+		this.jsonOutput = null;
+		this.httpStatus = new HTTPStatus(200, ExitCodes.SUCCESS);
+	}
+
+	/** Binary data returned from a module call */
+	public final InputStream inputStream;
+
+	/** Filename when downloading data from a module call */
+	public final String fileName;
+
+	/** HTTP error status */
+	public final HTTPStatus httpStatus;
+
+	/** When returning JSON; note that this should have priority over inputStream and fileName */
+	public final JSONObject jsonOutput;
+}

          
M src/org/overchat/overham/module/interfaces/Module.java +8 -0
@@ 43,6 43,8 @@ import java.util.List;
 import java.util.Map;
 
 import org.overchat.overham.Server;
+import org.overchat.overham.module.CallStyle;
+import org.overchat.overham.module.ReturnData;
 
 import net.sf.json.JSONObject;
 

          
@@ 71,4 73,10 @@ public interface Module {
 	public void init(Server server);
 
 	public String callAndDecode(String methodName, Map<String, String> requestParams);
+
+	public ReturnData callForData(final String methodName, Map<String, String> requestParams);
+
+	public String getUserMethodMimeType(String methodName); // Provided by Base
+
+	public CallStyle getUserMethodCallStyle(final String methodName); // Provided by Base
 }

          
M src/org/overchat/overham/modules/Band.java +14 -5
@@ 39,13 39,14 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
-import net.sf.json.*;
-
 import org.overchat.overham.Server;
 import org.overchat.overham.module.Base;
+import org.overchat.overham.module.CallStyle;
 import org.overchat.overham.module.ExitCodes;
 import org.overchat.overham.module.interfaces.Module;
 
+import net.sf.json.JSONObject;
+
 /**
  * This is the Band module.
  * This object provides access to a static database consisting of all known

          
@@ 77,8 78,8 @@ public class Band extends Base implement
 	@Override
 	public void init(Server server) {
 		super.init(server);
-		this.userMethodMap.put("lookup", null);
-		this.userMethodMap.put("search", null);
+		this.registerUserMethod("lookup", null, CallStyle.LEGACY);
+		this.registerUserMethod("search", null, CallStyle.LEGACY);
 	}
 
 	/**

          
@@ 118,13 119,17 @@ public class Band extends Base implement
 			// TODO: Log error
 			java.lang.System.out.println(e);
 			return null;
+		} catch (InterruptedException e) {
+			// TODO: Log error
+			java.lang.System.out.println(e);
+			return null;
 		}
 
 		return output;
 	}
 
 	/**
-	 * Find a band id, which may be passed to <b>lookup</b>..
+	 * Find a band id, which may be passed to <b>lookup</b>.
 	 * Zero is returned on failure to find such a band.
 	 *
 	 * The following criteria are recognized:

          
@@ 186,6 191,10 @@ public class Band extends Base implement
 			// TODO: Log error
 			java.lang.System.out.println(e);
 			return null;
+		} catch (InterruptedException e) {
+			// TODO: Log error
+			java.lang.System.out.println(e);
+			return null;
 		}
 
 		if (rowCount == 0) {

          
M src/org/overchat/overham/modules/Callsign.java +8 -3
@@ 42,6 42,7 @@ import java.util.Map;
 
 import org.overchat.overham.Server;
 import org.overchat.overham.module.Base;
+import org.overchat.overham.module.CallStyle;
 import org.overchat.overham.module.interfaces.Module;
 
 import net.sf.json.JSONObject;

          
@@ 53,7 54,8 @@ public class Callsign extends Base imple
 
 	/**
 	 * This is the main entrypoint which is called with the name of the method the
-	 * user requested to call.  A JSON structure is returned.
+	 * user requested to call. A JSON structure is returned.
+	 *
 	 * @param methodName
 	 * @param methodParams
 	 */

          
@@ 71,6 73,7 @@ public class Callsign extends Base imple
 	/**
 	 * Call the info function and return information about a callsign.
 	 * Returns a JSON structure.
+	 *
 	 * @param output
 	 * @param methodParams
 	 */

          
@@ 83,6 86,7 @@ public class Callsign extends Base imple
 	/**
 	 * Call the validate function and return information about a callsign.
 	 * Returns a JSON structure.
+	 *
 	 * @param output
 	 * @param methodParams
 	 */

          
@@ 96,12 100,13 @@ public class Callsign extends Base imple
 	 * Initialize the module (called by Server after loading).
 	 * Should not be called subsequently.
 	 * Automagically sets the owner field
+	 *
 	 * @param server
 	 */
 	@Override
 	public void init(Server server) {
 		super.init(server);
-		this.userMethodMap.put("info", null);
-		this.userMethodMap.put("validate", null);
+		this.registerUserMethod("info", null, CallStyle.LEGACY);
+		this.registerUserMethod("validate", null, CallStyle.LEGACY);
 	}
 }

          
M src/org/overchat/overham/modules/Dummy.java +3 -1
@@ 42,6 42,7 @@ import java.util.Map;
 
 import org.overchat.overham.Server;
 import org.overchat.overham.module.Base;
+import org.overchat.overham.module.CallStyle;
 import org.overchat.overham.module.interfaces.Module;
 
 import net.sf.json.JSONObject;

          
@@ 56,6 57,7 @@ public class Dummy extends Base implemen
 	/**
 	 * This is the main entrypoint which is called with the name of the method the
 	 * user requested to call.  A JSON structure is returned.
+	 *
 	 * @param name
 	 */
 	@Override

          
@@ 75,6 77,6 @@ public class Dummy extends Base implemen
 	@Override
 	public void init(Server server) {
 		super.init(server);
-		this.userMethodMap.put("noop", null);
+		this.registerUserMethod("noop", null, CallStyle.LEGACY);
 	}
 }

          
M src/org/overchat/overham/modules/Index.java +4 -2
@@ 47,6 47,7 @@ import java.util.Map;
 import org.apache.commons.io.IOUtils;
 import org.overchat.overham.Server;
 import org.overchat.overham.module.Base;
+import org.overchat.overham.module.CallStyle;
 import org.overchat.overham.module.ExitCodes;
 import org.overchat.overham.module.interfaces.Module;
 

          
@@ 61,6 62,7 @@ public class Index extends Base implemen
 	/**
 	 * This is the main entrypoint which is called with the name of the method the
 	 * user requested to call.  A JSON structure is returned.
+	 *
 	 * @param name
 	 */
 	@Override

          
@@ 99,8 101,8 @@ public class Index extends Base implemen
 	@Override
 	public void init(Server server) {
 		super.init(server);
-		this.userMethodMap.put("routes", null);
-		this.userMethodMap.put("", null); // Special entry for '/index' itself
+		this.registerUserMethod("routes", null, CallStyle.LEGACY);
+		this.registerUserMethod("", null, CallStyle.LEGACY); // Special entry for '/index' itself
 	}
 
 	private String readIndex() {

          
M src/org/overchat/overham/modules/Legacy.java +7 -5
@@ 50,6 50,7 @@ import java.util.Map;
 
 import org.overchat.overham.Server;
 import org.overchat.overham.module.Base;
+import org.overchat.overham.module.CallStyle;
 import org.overchat.overham.module.interfaces.Module;
 
 import net.sf.json.JSONArray;

          
@@ 65,6 66,7 @@ public class Legacy extends Base impleme
 	/**
 	 * This is the main entrypoint which is called with the name of the method the
 	 * user requested to call.  A JSON structure is returned.
+	 *
 	 * @param name
 	 */
 	@Override

          
@@ 72,7 74,7 @@ public class Legacy extends Base impleme
 		JSONObject jsonObject = new JSONObject();
 
 		if (methodName.equals("passthru")) {
-			//return userPassthru(jsonObject, module, method, value);
+			// return userPassthru(jsonObject, module, method, value);
 			return userPassthru(jsonObject, requestParams);
 		}
 

          
@@ 121,7 123,7 @@ public class Legacy extends Base impleme
 		java.lang.System.out.println("Attempting to access " + url);
 		try {
 			output = readJsonFromUrl(url.toString());
-		} catch (IOException|JSONException e) {
+		} catch (IOException | JSONException e) {
 			// TODO logger
 			java.lang.System.out.println(e);
 		}

          
@@ 131,9 133,9 @@ public class Legacy extends Base impleme
 	@Override
 	public void init(Server server) {
 		super.init(server);
-		this.userMethodMap.put("passthru", null);
+		this.registerUserMethod("passthru", null, CallStyle.LEGACY);
 	}
-	
+
 	private static String readAll(Reader rd) throws IOException {
 		StringBuilder sb = new StringBuilder();
 		int cp;

          
@@ 151,7 153,7 @@ public class Legacy extends Base impleme
 			String jsonText = "[" + readAll(rd) + "]";
 			JSONArray hostArray = JSONArray.fromObject(jsonText);
 
-			for(int i = 0; i < hostArray.size(); i++) {
+			for (int i = 0; i < hostArray.size(); i++) {
 				json = hostArray.getJSONObject(i);
 			}
 			return json;

          
M src/org/overchat/overham/modules/QCode.java +41 -16
@@ 42,17 42,19 @@ import java.util.Map;
 import java.util.Set;
 
 import org.overchat.overham.Server;
+import org.overchat.overham.module.Base;
+import org.overchat.overham.module.CallStyle;
+import org.overchat.overham.module.ExitCodes;
+import org.overchat.overham.module.interfaces.Module;
 import org.overchat.overham.modules.qcode.Authority;
 import org.overchat.overham.modules.qcode.RangeLimits;
-import org.overchat.overham.module.Base;
-import org.overchat.overham.module.ExitCodes;
-import org.overchat.overham.module.interfaces.Module;
 
 import net.sf.json.JSONObject;
 
 /**
  * This is the QCode module.  This provides QCode lookups for all known QCodes.
  * https://en.wikipedia.org/wiki/Q_code
+ *
  * @author duncan.palmer
  */
 public class QCode extends Base implements Module {

          
@@ 61,9 63,9 @@ public class QCode extends Base implemen
 	 * This method returns a copy of the string as it is passed, except
 	 * anything beginning with 'Q' is stripped of that leading character.
 	 * This is used before database lookups, so that we do not waste space in the database.
-	 * If 'QSL' is passed, 'SL' is returned.  If 'SL' is passed, 'SL' is returned.
+	 * If 'QSL' is passed, 'SL' is returned. If 'SL' is passed, 'SL' is returned.
 	 * Lowercase is always returned as uppercase.
-	 * 
+	 *
 	 * The returned string is always two characters or none, and null is never returned.
 	 * Therefore this makes for a very good detainter for user-input.
 	 */

          
@@ 71,8 73,7 @@ public class QCode extends Base implemen
 		final String failStr = new String("");
 		String qCodeOutput = new String(failStr);
 
-		if (qCodeInput == null)
-			return qCodeOutput; // Oops!  Make it safe.
+		if (qCodeInput == null) return qCodeOutput; // Oops! Make it safe.
 
 		qCodeOutput = qCodeInput.toUpperCase(); // Only deal with uppercase from here
 

          
@@ 156,6 157,7 @@ public class QCode extends Base implemen
 	/**
 	 * This is the main entrypoint which is called with the name of the method the
 	 * user requested to call.  A JSON structure is returned.
+	 *
 	 * @param methodName
 	 */
 	@Override

          
@@ 167,6 169,8 @@ public class QCode extends Base implemen
 			return userAuthority(jsonObject, methodParams);
 		} else if (methodName.equals("lookup")) {
 			return userLookup(jsonObject, methodParams);
+		} else if (methodName.equals("dump")) {
+			return userDump(jsonObject);
 		}
 		return null;
 	}

          
@@ 218,12 222,7 @@ public class QCode extends Base implemen
 			boundParams.add(code);
 		}
 
-		try (
-			ResultSet rs = this.owner.getHamDatabase().query(
-				"SELECT meaningTx, meaningRx FROM qcodes WHERE code = ?",
-				boundParams
-			)
-		) {
+		try (ResultSet rs = this.owner.getHamDatabase().query("SELECT meaningTx, meaningRx FROM qcodes WHERE code = ?", boundParams)) {
 			if (rs.next()) {
 				Map<String, String> data = new HashMap<String, String>(3);
 				data.put("tx", rs.getString("meaningTx"));

          
@@ 238,16 237,42 @@ public class QCode extends Base implemen
 			// TODO: Log error
 			java.lang.System.out.println(e);
 			return null;
+		} catch (InterruptedException e) {
+			// TODO: Log error
+			java.lang.System.out.println(e);
+			return null;
 		}
 
 		return output;
 	}
 
+	public JSONObject userDump(JSONObject output) {
+		List<String> dump = new ArrayList<String>();
+		try (ResultSet rs = this.owner.getHamDatabase().query("SELECT code FROM qcodes")) {
+			while (rs.next()) {
+				dump.add("Q" + rs.getString("code"));
+			}
+		} catch (SQLException e) {
+			// TODO: Log error
+			java.lang.System.out.println(e);
+			return null;
+		} catch (InterruptedException e) {
+			// TODO: Log error
+			java.lang.System.out.println(e);
+			return null;
+		}
+
+		output.accumulate("data", dump);
+		output = this.buildErrorForUser(output, ExitCodes.SUCCESS);
+		return output;
+	}
+
 	@Override
 	public void init(Server server) {
 		super.init(server);
-		this.userMethodMap.put("validate", null);
-		this.userMethodMap.put("authority", null);
-		this.userMethodMap.put("lookup", null);
+		this.registerUserMethod("validate", null, CallStyle.LEGACY);
+		this.registerUserMethod("authority", null, CallStyle.LEGACY);
+		this.registerUserMethod("lookup", null, CallStyle.LEGACY);
+		this.registerUserMethod("dump", null, CallStyle.LEGACY);
 	}
 }

          
A => src/org/overchat/overham/modules/Recordings.java +427 -0
@@ 0,0 1,427 @@ 
+/*
+ * OverHam; An object-orientated ham radio math and tool library
+ * Copyright (c) 2015-2017, Duncan Ross Palmer (2E0EOL),
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ *     * Redistributions of source code must retain the above copyright notice,
+ *       this list of conditions and the following disclaimer.
+ *
+ *     * Redistributions in binary form must reproduce the above copyright
+ *       notice, this list of conditions and the following disclaimer in the
+ *       documentation and/or other materials provided with the distribution.
+ *
+ *     * Neither the name of the Daybo Logic nor the names of its contributors
+ *       may be used to endorse or promote products derived from this software
+ *       without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.overchat.overham.modules;
+
+import java.nio.file.FileSystems;
+import java.nio.file.Path;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.overchat.overham.HTTPStatus;
+import org.overchat.overham.Response;
+import org.overchat.overham.Server;
+import org.overchat.overham.module.Base;
+import org.overchat.overham.module.CallStyle;
+import org.overchat.overham.module.ExitCodes;
+import org.overchat.overham.module.ReturnData;
+import org.overchat.overham.module.interfaces.Module;
+
+import com.amazonaws.services.s3.model.GetObjectRequest;
+import com.amazonaws.services.s3.model.S3Object;
+import com.amazonaws.services.s3.model.S3ObjectInputStream;
+
+import net.sf.json.JSONObject;
+
+/**
+ * This is the Recordings module.  This provides search and download facilities for
+ * the many thousands of amateur radio recordings the project has made since 2016.
+ *
+ * The facilities include date, frequency and mode searches.
+ *
+ * @author duncan.palmer
+ */
+public class Recordings extends Base implements Module {
+
+	public Recordings() {
+	}
+
+	@Override
+	public ReturnData callForData(String methodName, Map<String, String> methodParams) {
+		JSONObject jsonObject = new JSONObject();
+		if (methodName.equals("download")) {
+			return userDownload(jsonObject, methodParams);
+		} else if (methodName.equals("search")) {
+			return userSearch(jsonObject, methodParams);
+		} else if (methodName.equals("stats")) {
+			return userStats(jsonObject);
+		} else if (methodName.equals("info")) {
+			return userInfo(jsonObject, methodParams);
+		}
+
+		return null;
+	}
+
+	public ReturnData userDownload(JSONObject output, Map<String, String> methodParams) {
+		List<Object> boundParams;
+		String id = methodParams.get("id");
+		S3ObjectInputStream objectStream = null;
+		String fileName = null;
+		List<String> implemented = new ArrayList<String>(1);
+		Response implementedResponse;
+
+		final String q = "SELECT public,uploaded,transcodedPath FROM ic7100voice voice, ic7100voiceExtra extra "
+		    + "WHERE voice.id=extra.id AND voice.id = ?";
+
+		if (null == id || 0 == id.length()) {
+			return new ReturnData(null, null, new HTTPStatus(400, ExitCodes.MISSING));
+		} else {
+			if (this.validateUUID(id)) {
+				boundParams = new ArrayList<Object>(1);
+				boundParams.add(id);
+			} else {
+				return new ReturnData(null, null, new HTTPStatus(400, ExitCodes.MALFORMED_ARGUMENT));
+			}
+		}
+
+		implemented.add("id");
+		implementedResponse = this.checkUnimplemented(methodParams, implemented);
+		if (!implementedResponse.success) {
+			java.lang.System.out.println(String.format("'%s' is not implemented", implementedResponse.what));
+			return new ReturnData(null, null, new HTTPStatus(412, ExitCodes.UNIMPLEMENTED));
+		}
+
+		try (ResultSet rs = this.owner.getHamDatabase().queryDynamic(q, boundParams)) {
+			if (rs.next()) {
+				if (!rs.getBoolean("public")) { // Recording is restricted
+					output = this.buildErrorForUser(output, ExitCodes.FORBIDDEN_ARTIFACT);
+					return new ReturnData(null, null, new HTTPStatus(451, ExitCodes.FORBIDDEN_ARTIFACT));
+				} else if (rs.getDate("uploaded") == null) { // Recording has not been uploaded
+					output = this.buildErrorForUser(output, ExitCodes.TRY_LATER);
+					return new ReturnData(null, null, new HTTPStatus(501, ExitCodes.TRY_LATER));
+				} else {
+					final String bucketName = "19ece944-89d6-11e7-a14b-a413fd9bf184";
+
+					java.lang.System.out.println("Fetching recording \"" + id + "\"");
+					S3Object s3Object = this.owner.getS3Client().getObject(new GetObjectRequest(bucketName, id));
+					java.lang.System.out.println(s3Object);
+					objectStream = s3Object.getObjectContent();
+
+					Path p4 = FileSystems.getDefault().getPath(rs.getString("transcodedPath"));
+					fileName = p4.getFileName().toString();
+				}
+			} else { // Nothing returned, so the metadata must be missing
+				output = this.buildErrorForUser(output, ExitCodes.NOT_FOUND);
+				return new ReturnData(null, null, new HTTPStatus(404, ExitCodes.MISSING));
+			}
+		} catch (SQLException e) {
+			java.lang.System.out.println(e); // TODO: Log error
+			return new ReturnData(null, null, new HTTPStatus(502, ExitCodes.FAILURE));
+		} catch (InterruptedException e) {
+			java.lang.System.out.println(e); // TODO: Log error
+			return new ReturnData(null, null, new HTTPStatus(500, ExitCodes.FAILURE));
+		}
+
+		return new ReturnData(objectStream, fileName);
+	}
+
+	public ReturnData userInfo(JSONObject output, Map<String, String> methodParams) {
+		List<Object> boundParams;
+		String id = methodParams.get("id");
+		Map<String, Object> data = new HashMap<String, Object>(8);
+		ExitCodes exitCode;
+		List<String> implemented = new ArrayList<String>(1);
+		Response implementedResponse;
+		final String q = "SELECT whenStart, msLength, hzFreq, mode, band, direction, callsign, model, maidenhead FROM ic7100voiceExtra WHERE id = ?";
+
+		if (null == id || 0 == id.length()) {
+			output = this.buildErrorForUser(output, ExitCodes.MISSING);
+			return new ReturnData(output, new HTTPStatus(400, ExitCodes.MISSING));
+		} else {
+			if (this.validateUUID(id)) {
+				boundParams = new ArrayList<Object>(1);
+				boundParams.add(id);
+			} else {
+				return new ReturnData(null, null, new HTTPStatus(400, ExitCodes.MALFORMED_ARGUMENT));
+			}
+		}
+
+		implemented.add("id");
+		implementedResponse = this.checkUnimplemented(methodParams, implemented);
+		if (!implementedResponse.success) {
+			java.lang.System.out.println(String.format("'%s' is not implemented", implementedResponse.what));
+			return new ReturnData(null, null, new HTTPStatus(412, ExitCodes.UNIMPLEMENTED));
+		}
+
+		try (ResultSet rs = this.owner.getHamDatabase().queryDynamic(q, boundParams)) {
+			if (rs.next()) {
+				data.put("whenStart", this.isoDate(rs.getString("whenStart")));
+				data.put("length", rs.getInt("msLength"));
+				data.put("hzFreq", rs.getInt("hzFreq"));
+				data.put("mode", rs.getString("mode"));
+				data.put("band", rs.getInt("band"));
+				data.put("direction", rs.getString("direction"));
+				data.put("callsign", rs.getString("callsign"));
+				data.put("model", rs.getString("model"));
+				data.put("maidenhead", rs.getString("maidenhead"));
+				exitCode = ExitCodes.SUCCESS;
+			} else {
+				return new ReturnData(null, null, new HTTPStatus(404, ExitCodes.NOT_FOUND));
+			}
+		} catch (SQLException e) {
+			// TODO: Log error
+			java.lang.System.out.println(e);
+			return null;
+		} catch (InterruptedException e) {
+			// TODO: Log error
+			java.lang.System.out.println(e);
+			return null;
+		}
+
+		output.accumulate("data", data);
+		output = this.buildErrorForUser(output, exitCode);
+		return new ReturnData(output);
+	}
+
+	// FIXME: Need to ensure all criteria are integer, for security
+	public ReturnData userSearch(JSONObject output, Map<String, String> methodParams) {
+		List<String> implemented = new ArrayList<String>(2);
+		List<Object> boundParams = new ArrayList<Object>();
+		String hzFreq = methodParams.get("hzFreq"); // A string in the form "lowest-highest"
+		String when = methodParams.get("when"); // A string in the form "earliest-latest"
+		Map<String, Object> data = new HashMap<String, Object>();
+		List<Object> resultList = new ArrayList<Object>(); // Size depends on result list size
+		ExitCodes exitCode = ExitCodes.FAILURE;
+		final String baseQ = "SELECT id, whenStart, msLength, hzFreq FROM ic7100voiceExtra WHERE ";
+		StringBuilder q = new StringBuilder();
+		Response implementedResponse;
+
+		implemented.add("hzFreq");
+		implemented.add("when");
+		implementedResponse = this.checkUnimplemented(methodParams, implemented);
+		if (!implementedResponse.success) {
+			java.lang.System.out.println(String.format("'%s' is not implemented", implementedResponse.what));
+			return new ReturnData(null, null, new HTTPStatus(412, ExitCodes.UNIMPLEMENTED));
+		}
+
+		q.append(baseQ); // Start out with the base query
+
+		if (hzFreq != null) { // Optional
+			int dashSep = hzFreq.indexOf("-");
+			if (dashSep == -1) { // Oops, you didn't put a hyphen for the frequency lower-upper
+				return new ReturnData(
+					this.buildErrorForUser(output, ExitCodes.MALFORMED_ARGUMENT),
+					new HTTPStatus(400, ExitCodes.MALFORMED_ARGUMENT)
+				);
+			}
+			String lowestStr = hzFreq.substring(0, dashSep);
+			String highestStr = hzFreq.substring(dashSep + 1, hzFreq.length());
+
+			Integer lowest = Integer.valueOf(lowestStr);
+			Integer highest = Integer.valueOf(highestStr);
+
+			if (highest < lowest) {
+				return new ReturnData(
+					this.buildErrorForUser(output, ExitCodes.RANGE),
+					new HTTPStatus(400, ExitCodes.RANGE)
+				);
+			}
+
+			q.append("hzFreq BETWEEN ? AND ? ");
+			boundParams.add(lowest);
+			boundParams.add(highest);
+		}
+
+		if (boundParams.size() > 0) {
+			q.append("AND ");
+		}
+
+		if (when != null) { // Optional
+			int dashSep = when.indexOf("-");
+			if (dashSep == -1) { // Oops
+				return new ReturnData(
+					this.buildErrorForUser(output, ExitCodes.MALFORMED_ARGUMENT),
+					new HTTPStatus(400, ExitCodes.MALFORMED_ARGUMENT)
+				);
+			}
+			String earliest = when.substring(0, dashSep);
+			String latest = when.substring(dashSep + 1, when.length());
+			q.append("(UNIX_TIMESTAMP(whenStart) >= ? AND UNIX_TIMESTAMP(whenStart) <= ?) ");
+			boundParams.add(earliest);
+			boundParams.add(latest);
+		}
+
+		q.append("ORDER BY whenStart LIMIT 1000");
+
+		if (boundParams.size() == 0) { // You didn't specify enough to build a query
+			return new ReturnData(null, null, new HTTPStatus(400, ExitCodes.MISSING));
+		} else {
+			try (ResultSet rs = this.owner.getHamDatabase().queryDynamic(q.toString(), boundParams)) {
+				rs.last();
+				int rowCount = rs.getRow();
+				rs.beforeFirst();
+
+				while (rs.next()) {
+					HashMap<String, String> oneResult = new HashMap<String, String>(4);
+
+					oneResult.put("id", rs.getString("id"));
+					oneResult.put("whenStart", rs.getString("whenStart"));
+					oneResult.put("duration", rs.getString("msLength"));
+					oneResult.put("hzFreq", rs.getString("hzFreq"));
+
+					resultList.add(oneResult);
+				}
+				data.put("resultCount", rowCount);
+				data.put("results", resultList);
+				exitCode = ExitCodes.SUCCESS;
+			} catch (SQLException e) {
+				// TODO: Log error
+				java.lang.System.out.println(e);
+				return null;
+			} catch (InterruptedException e) {
+				// TODO: Log error
+				java.lang.System.out.println(e);
+				return null;
+			}
+		}
+
+		output.accumulate("data", data);
+		output = this.buildErrorForUser(output, exitCode);
+		return new ReturnData(output, new HTTPStatus(200, exitCode));
+	}
+
+	public ReturnData userStats(JSONObject output) {
+		Map<String, String> data = new HashMap<String, String>(3);
+		final String q = "SELECT COUNT(uploaded) uploadedCount,COUNT(id) totalCount, MAX(mtime) " +
+		    "lastDump FROM ic7100voice";
+
+		final String q2 = "SELECT COUNT(public) available FROM ic7100voice voice, ic7100voiceExtra extra " +
+		    "WHERE voice.id=extra.id AND public != 0 AND uploaded != 0";
+
+		final String q3 = "SELECT MIN(whenStart) earliest, MAX(whenStart) latest, " +
+		    "SUM(msLength) totalTimeSecs, AVG(msLength) avgLengthSecs FROM ic7100voiceExtra";
+
+		final String q4 = "SELECT COUNT(mode) `count`, `mode` FROM ic7100voiceExtra GROUP BY mode ORDER by `count`";
+
+		// This query fetches the number of uploaded recordings, the total number in the DB and the last time
+		// the admin dumped some files from any radio.
+		try (ResultSet rs = this.owner.getHamDatabase().queryDynamic(q)) {
+			while (rs.next()) {
+				data.put("uploadedCount", rs.getString("uploadedCount"));
+				data.put("totalCount", rs.getString("totalCount"));
+				data.put("lastDump", this.isoDate(rs.getString("lastDump")));
+			}
+		} catch (SQLException e) {
+			// TODO: Log error
+			java.lang.System.out.println(e);
+			return null;
+		} catch (InterruptedException e) {
+			// TODO: Log error
+			java.lang.System.out.println(e);
+			return null;
+		}
+
+		// This query will get the number of available recording "right now" for anybody
+		try (ResultSet rs = this.owner.getHamDatabase().queryDynamic(q2)) {
+			while (rs.next()) {
+				data.put("available", rs.getString("available"));
+			}
+		} catch (SQLException e) {
+			// TODO: Log error
+			java.lang.System.out.println(e);
+			return null;
+		} catch (InterruptedException e) {
+			// TODO: Log error
+			java.lang.System.out.println(e);
+			return null;
+		}
+
+		// This query gets averages about metadata only, and other useful facts
+		try (ResultSet rs = this.owner.getHamDatabase().queryDynamic(q3)) {
+			while (rs.next()) {
+				data.put("earliest", this.isoDate(rs.getString("earliest")));
+				data.put("latest", this.isoDate(rs.getString("latest")));
+			}
+		} catch (SQLException e) {
+			// TODO: Log error
+			java.lang.System.out.println(e);
+			return null;
+		} catch (InterruptedException e) {
+			// TODO: Log error
+			java.lang.System.out.println(e);
+			return null;
+		}
+
+		// Query for mode distribution:
+		try (ResultSet rs = this.owner.getHamDatabase().queryDynamic(q4)) {
+			while (rs.next()) {
+				final String mode = rs.getString("mode");
+				if (0 == mode.length())
+					continue;
+				data.put("mode:" + mode, rs.getString("count"));
+			}
+		} catch (SQLException e) {
+			// TODO: Log error
+			java.lang.System.out.println(e);
+			return null;
+		} catch (InterruptedException e) {
+			// TODO: Log error
+			java.lang.System.out.println(e);
+			return null;
+		}
+
+		// Query for direction distribution:
+		// SELECT COUNT(direction) `count`, `direction` FROM ic7100voiceExtra GROUP BY direction ORDER by `count`
+
+		// Distribution by frequency
+		// SELECT COUNT(hzFreq) `count`, `hzFreq` FROM ic7100voiceExtra GROUP BY hzFreq ORDER by `hzFreq`
+
+		// Distribution by callsign
+		// SELECT COUNT(callsign) `count`, callsign FROM ic7100voiceExtra GROUP BY callsign ORDER BY `count`
+
+		// Distribution by locator
+		// SELECT COUNT(maidenhead) `count`, maidenhead FROM ic7100voiceExtra GROUP BY maidenhead ORDER BY `count`
+
+		// Distribution by model
+		// SELECT COUNT(model) `count`, model FROM ic7100voiceExtra GROUP BY model ORDER BY `count`
+
+		// Distribution by band (you will need to get band names separately, from the Band module
+		// SELECT COUNT(band) `count`, band FROM ic7100voiceExtra GROUP BY band ORDER BY `count`
+
+		output.accumulate("data", data);
+		output = this.buildErrorForUser(output, ExitCodes.SUCCESS);
+		return new ReturnData(output, new HTTPStatus(200, ExitCodes.SUCCESS));
+	}
+
+	@Override
+	public void init(Server server) {
+		super.init(server);
+		this.registerUserMethod("download", "application/octet-stream", CallStyle.RETURN_DATA);
+		this.registerUserMethod("info", null, CallStyle.RETURN_DATA);
+		this.registerUserMethod("search", null, CallStyle.RETURN_DATA);
+		this.registerUserMethod("stats", null, CallStyle.RETURN_DATA);
+	}
+
+}

          
M src/org/overchat/overham/modules/Resistor.java +12 -10
@@ 43,6 43,7 @@ import java.util.Map;
 
 import org.overchat.overham.Server;
 import org.overchat.overham.module.Base;
+import org.overchat.overham.module.CallStyle;
 import org.overchat.overham.module.ExitCodes;
 import org.overchat.overham.module.interfaces.Module;
 import org.overchat.overham.modules.resistor.Color;

          
@@ 61,6 62,7 @@ public class Resistor extends Base imple
 	/**
 	 * This is the main entrypoint which is called with the name of the method the
 	 * user requested to call.  A JSON structure is returned.
+	 *
 	 * @param name
 	 */
 	@Override

          
@@ 77,7 79,7 @@ public class Resistor extends Base imple
 	}
 
 	public JSONObject userEncode(JSONObject output, Map<String, String> methodParams) {
-		Float ohms = (float) 0;
+		Float ohms = (float)0;
 		String ohmsStr = methodParams.get("ohms");
 		if (ohmsStr != null) {
 			ohms = Float.parseFloat(ohmsStr);

          
@@ 85,19 87,19 @@ public class Resistor extends Base imple
 
 		ColorCode colorCode = this.encoder.codeFromValue(ohms);
 		Integer bandCount = colorCode.bandCount();
-		Map<String, String> bands = new HashMap<String, String>(); 
+		Map<String, String> bands = new HashMap<String, String>();
 
 		for (Integer i = 0; i < bandCount; i++) {
 			String key = String.format("band%d", i);
 			bands.put(
-				key,
-				String.format("%d", colorCode.getColor(i).ordinal())
+			        key,
+			        String.format("%d", colorCode.getColor(i).ordinal())
 			);
 			// TODO: In terse mode, continue afore this point
 			key = key + "str";
 			bands.put(
-				key,
-				colorCode.getColor(i).toString()
+			        key,
+			        colorCode.getColor(i).toString()
 			);
 		}
 		output.accumulate("data", bands);

          
@@ 111,8 113,8 @@ public class Resistor extends Base imple
 
 		for (Color color : Color.values()) {
 			map.put(
-				String.format("%d", color.ordinal()),
-				color.toString()
+			        String.format("%d", color.ordinal()),
+			        color.toString()
 			);
 		}
 

          
@@ 132,7 134,7 @@ public class Resistor extends Base imple
 	public void init(Server server) {
 		super.init(server);
 		this.encoder = new Encoder();
-		this.userMethodMap.put("encode", null);
-		this.userMethodMap.put("decode", null);
+		this.registerUserMethod("encode", null, CallStyle.LEGACY);
+		this.registerUserMethod("decode", null, CallStyle.LEGACY);
 	}
 }

          
M src/org/overchat/overham/modules/System.java +9 -8
@@ 36,13 36,14 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
-import net.sf.json.*;
-
 import org.overchat.overham.Server;
 import org.overchat.overham.module.Base;
+import org.overchat.overham.module.CallStyle;
 import org.overchat.overham.module.ExitCodes;
 import org.overchat.overham.module.interfaces.Module;
 
+import net.sf.json.JSONObject;
+
 /**
  * This is the System module.  This provides basic services which are not
  * related to amateur radio.  It allows remote shutdown, service health checking,

          
@@ 59,7 60,7 @@ public class System extends Base impleme
 	}
 
 	protected Long uptime() {
-		Long uptime = new Long((java.lang.System.currentTimeMillis() - this.owner.getStartTime())/ 1000L);
+		Long uptime = new Long((java.lang.System.currentTimeMillis() - this.owner.getStartTime()) / 1000L);
 		return uptime;
 	}
 

          
@@ 69,7 70,7 @@ public class System extends Base impleme
 
 	protected JSONObject userUptime(JSONObject output) {
 		Long uptime = this.uptime();
-		HashMap<String,Long> data = new HashMap<String,Long>();
+		HashMap<String, Long> data = new HashMap<String, Long>();
 		data.put("seconds", uptime);
 		output.accumulate("data", data);
 		return output;

          
@@ 137,9 138,9 @@ public class System extends Base impleme
 	@Override
 	public void init(Server server) {
 		super.init(server);
-		this.userMethodMap.put("modules", null);
-		this.userMethodMap.put("uptime", null);
-		this.userMethodMap.put("error", null);
-		this.userMethodMap.put("ping", null);
+		this.registerUserMethod("modules", null, CallStyle.LEGACY);
+		this.registerUserMethod("uptime", null, CallStyle.LEGACY);
+		this.registerUserMethod("error", null, CallStyle.LEGACY);
+		this.registerUserMethod("ping", null, CallStyle.LEGACY);
 	}
 }

          
M test/org/overchat/overham/db/HamDatabaseTest.java +11 -5
@@ 32,7 32,7 @@ 
 
 package org.overchat.overham.db;
 
-import static org.junit.Assert.*;
+import static org.junit.Assert.assertEquals;
 
 import java.sql.ResultSet;
 import java.sql.SQLException;

          
@@ 44,14 44,18 @@ import org.junit.AfterClass;
 import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Test;
+import org.overchat.overham.config.Configuration;
 
 /**
  * @author duncan.palmer
  * Note that this test class is derived from HamDatabase because
- * query() will be made protected in a later version.  You will need
+ * query() will be made protected in a later version. You will need
  * to derive a specific per-module class!
  */
-public class HamDatabaseTest extends HamDatabase {
+public class HamDatabaseTest {
+
+	private Configuration config;
+	private HamDatabase sut;
 
 	/**
 	 * @throws java.lang.Exception

          
@@ 72,6 76,8 @@ public class HamDatabaseTest extends Ham
 	 */
 	@Before
 	public void setUp() throws Exception {
+		this.config = new Configuration();
+		this.sut = new HamDatabase(config);
 	}
 
 	/**

          
@@ 82,12 88,12 @@ public class HamDatabaseTest extends Ham
 	}
 
 	@Test
-	public void test() throws SQLException, ClassNotFoundException {
+	public void test() throws SQLException, ClassNotFoundException, InterruptedException {
 		List<String> expect = new ArrayList<String>(1);
 		expect.add("Can you acknowledge receipt?");
 
 		List<String> actual = new ArrayList<String>(1);
-		ResultSet resultSet = this.query("SELECT meaningTx FROM qcodes WHERE code = 'SL'");
+		ResultSet resultSet = this.sut.query("SELECT meaningTx FROM qcodes WHERE code = 'SL'");
 		while (resultSet.next()) {
 			actual.add(resultSet.getString("meaningTx"));
 		}