diff --git a/.gitignore b/.gitignore index 74b926f..9297324 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,7 @@ docs/ # Code coverage *.lst + +# Compiled executable +gopro-ingester +gopro-ingester.exe diff --git a/README.md b/README.md index c0c4a0e..b98937e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,25 @@ -# gopro-ingester -Simple program for ingesting content from a GoPro media card. +# GoPro-Ingester +A simple tool for ingesting footage from a GoPro media card. + +### Getting Started +You can either directly download one of the executable binaries available on the **releases** page, or follow the instructions below to build it yourself: +```shell +git clone git@github.com:andrewlalis/gopro-ingester.git +cd gopro-ingester +dub build +``` +> You'll need to have the D compiler toolchain and Dub installed to do this. + +### Ingesting Footage +To ingest footage, simply run the executable. It will search for a GoPro media card on your file system, and if one is found, it'll begin copying data to an output directory. By default, it copies to a directory named `raw`, relative to where you invoked the program. + +It'll only copy `.MP4` and `.WAV` files, and ignores `.THM` and `.LVM` files that are only used by GoPro's own apps. + +This tool comes with a variety of options, which you can view by running `gopro-ingester --help`. I've also included a brief overview of them here: + +- `--mediaDir` - The base directory from which to begin searching for a GoPro media card. +- `--outputDir` - The directory to copy data to. +- `--force` - Forcibly overwrite existing files. +- `--dryRun` - Perform a dry-run (and don't actually copy anything). +- `--bufferSize` - The size of the memory buffer for copying. +- `--help` - Shows help information. diff --git a/dub.json b/dub.json new file mode 100644 index 0000000..92d353a --- /dev/null +++ b/dub.json @@ -0,0 +1,14 @@ +{ + "authors": [ + "Andrew Lalis" + ], + "copyright": "Copyright © 2022, Andrew Lalis", + "dependencies": { + "dsh": "~>1.6.1", + "filesizes": "~>1.1.0", + "progress": "~>5.0.2" + }, + "description": "Simple program for ingesting content from a GoPro media card.", + "license": "MIT", + "name": "gopro-ingester" +} \ No newline at end of file diff --git a/dub.selections.json b/dub.selections.json new file mode 100644 index 0000000..6cb2bc6 --- /dev/null +++ b/dub.selections.json @@ -0,0 +1,8 @@ +{ + "fileVersion": 1, + "versions": { + "dsh": "1.6.1", + "filesizes": "1.1.0", + "progress": "5.0.2" + } +} diff --git a/source/app.d b/source/app.d new file mode 100644 index 0000000..504c37e --- /dev/null +++ b/source/app.d @@ -0,0 +1,160 @@ +import dsh; +import std.typecons; +import std.path; +import std.string; +import std.algorithm; +import std.getopt; +import filesizes; +import progress; + +const DEFAULT_OUTPUT_DIR = "raw"; +const DEFAULT_MEDIA_DIR = "/media"; +const GOPRO_CONTENT_DIR = "DCIM/100GOPRO"; +const DEFAULT_BUFFER_SIZE = 1024 * 1024; + +int main(string[] args) { + writeln( + "+---------------------------------+\n" ~ + "| |\n" ~ + "| GoPro Ingester |\n" ~ + "| v1.0.0 |\n" ~ + "| by Andrew Lalis |\n" ~ + "| |\n" ~ + "+---------------------------------+\n" + ); + + string mediaSearchDir = DEFAULT_MEDIA_DIR; + string outputDir = buildPath(getcwd(), DEFAULT_OUTPUT_DIR); + size_t bufferSize = DEFAULT_BUFFER_SIZE; + bool force = false; + bool dryRun = false; + + auto helpInfo = getopt( + args, + "mediaDir|i", + format!"The base directory from which to search for the GoPro media. Defaults to \"%s\"."(mediaSearchDir), + &mediaSearchDir, + "outputDir|o", + format!"The directory to copy data to. Defaults to \"%s\". Will create the directory if it doesn't exist yet."(outputDir), + &outputDir, + "force|f", + format!"Whether to forcibly overwrite existing files. Defaults to %s."(force), + &force, + "dryRun|d", + format!"Whether to perform a dry-run (don't actually copy anything). Defaults to %s."(dryRun), + &dryRun, + "bufferSize|b", + format!"The size of the buffer for copying files, in bytes. Defaults to %s."(formatFilesize(DEFAULT_BUFFER_SIZE)), + &bufferSize + ); + + if (helpInfo.helpWanted) { + defaultGetoptPrinter("Ingestion tool for importing data from GoPro media cards.", helpInfo.options); + return 0; + } + + auto nullableGoProDir = getGoProDir(mediaSearchDir); + if (nullableGoProDir.isNull) { + error("Couldn't find GoPro directory."); + return 1; + } + string goProDir = nullableGoProDir.get(); + writefln!"Found GoPro media at %s."(goProDir); + return copyFiles(goProDir, outputDir, bufferSize, force, dryRun); +} + +/** + * Tries to find a GoPro's media directory. + * Params: + * baseDir = The base directory to start the search from. + * Returns: A nullable string that, if present, refers to the GoPro's media + * directory. + */ +Nullable!string getGoProDir(string baseDir) { + if (!exists(baseDir) || !isDir(baseDir)) return Nullable!string.init; + foreach (dir; std.file.dirEntries(baseDir, SpanMode.breadth)) { + string mediaPath = buildPath(dir.name, GOPRO_CONTENT_DIR); + if (exists(mediaPath) && isDir(mediaPath)) { + return nullable(mediaPath); + } + } + return Nullable!string.init; +} + +/** + * Determines if we should copy a given file from the media card to the local + * directory. + * Params: + * entry = The entry for the file at its source. + * targetFile = The place to copy the file to. + * force = Whether the force flag has been set. + * Returns: True if we should copy the file, or false otherwise. + */ +bool shouldCopyFile(DirEntry entry, string targetFile, bool force) { + return entry.isFile() && + (entry.name.endsWith(".MP4") || entry.name.endsWith(".WAV")) && + (!exists(targetFile) || getSize(targetFile) != entry.size || force); +} + +/** + * Copies files from a source to a target directory. + * Params: + * sourceDir = The source directory. + * targetDir = The target directory. + * bufferSize = The buffer size to use when copying. + * force = Whether to overwrite existing files. + * dryRun = Whether to perform a dry-run. + * Returns: An exit code. + */ +int copyFiles(string sourceDir, string targetDir, size_t bufferSize, bool force, bool dryRun) { + if (!exists(targetDir) && !dryRun) mkdirRecurse(targetDir); + DirEntry[] filesToCopy; + ulong totalFileSize = 0; + foreach (DirEntry entry; dirEntries(sourceDir, SpanMode.shallow)) { + string targetFile = buildPath(targetDir, baseName(entry.name)); + if (shouldCopyFile(entry, targetFile, force)) { + filesToCopy ~= entry; + totalFileSize += entry.size; + } + } + + if (filesToCopy.length == 0) { + writeln("No new files to copy."); + return 0; + } + + if (getAvailableDiskSpace(targetDir) < totalFileSize) { + writefln!"Not enough disk space to copy all files: %s available, %s needed."( + formatFilesize(getAvailableDiskSpace(targetDir)), + formatFilesize(totalFileSize) + ); + return 1; + } + + writefln!"Copying %d files (%s) to %s."(filesToCopy.length, formatFilesize(totalFileSize), targetDir); + if (dryRun) writeln("(Dry Run)"); + Bar progressBar = new FillingSquaresBar(); + progressBar.width = 80; + progressBar.max = totalFileSize; + progressBar.start(); + ubyte[] buffer = new ubyte[bufferSize]; + foreach (DirEntry entry; filesToCopy) { + string filename = baseName(entry.name); + string targetFile = buildPath(targetDir, filename); + string verb = exists(targetFile) ? "Overwriting" : "Copying"; + progressBar.message = { return std.string.format!"%s %s (%s)"(verb, filename, formatFilesize(entry.size)); }; + if (!dryRun) { + File inputFile = File(entry.name, "rb"); + File outputFile = File(targetFile, "wb"); + foreach (ubyte[] localBuffer; inputFile.byChunk(buffer)) { + outputFile.rawWrite(localBuffer); + progressBar.next(localBuffer.length); + } + } else { + progressBar.next(getSize(entry.name)); + } + } + progressBar.finish(); + + return 0; +}