Skip to content

Commit

Permalink
Improve Exports
Browse files Browse the repository at this point in the history
- moved FFmpeg metadata into a separate library and process.  This is better
- Allow attachments with type GIF and JPEG
- Fixed ZIP export names containing quotes
- comments
  • Loading branch information
benpate committed Nov 8, 2024
1 parent f7041f9 commit 70b2ffc
Show file tree
Hide file tree
Showing 6 changed files with 191 additions and 12 deletions.
4 changes: 3 additions & 1 deletion build/step_Export.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package build
import (
"archive/zip"
"io"
"strings"

"github.com/benpate/derp"
"github.com/benpate/rosetta/translate"
Expand Down Expand Up @@ -43,7 +44,8 @@ func (step StepExport) Get(builder Builder, buffer io.Writer) PipelineBehavior {
}

// Done.
filename := streamBuilder._stream.Label + ".zip"
filename := strings.ReplaceAll(streamBuilder._stream.Label, `"`, "")
filename = filename + ".zip"
return Halt().AsFullPage().WithContentType("application/zip").WithHeader(`Content-Disposition`, `attachment; filename="`+filename+`"`)
}

Expand Down
9 changes: 7 additions & 2 deletions model/attachmentRules.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,19 @@ func (rules AttachmentRules) FileSpec(address *url.URL, mediaCategory string) me
}
}

// Map duplicate extensions to the canonical value
switch extension {
case "jpg":
extension = "jpeg"
}

// Calculate default types if none is provided
if len(rules.Extensions) == 0 {

switch mediaCategory {

case "image":
rules.Extensions = []string{"webp", "png", "jpg"}
rules.Extensions = []string{"webp", "png", "jpeg", "gif"}

case "video":
rules.Extensions = []string{"mp4", "webm", "ogv"}
Expand All @@ -93,7 +99,6 @@ func (rules AttachmentRules) FileSpec(address *url.URL, mediaCategory string) me
Width: width,
Height: height,
MimeType: mime.TypeByExtension(extension),
Metadata: make(map[string]string),
}

return result
Expand Down
30 changes: 21 additions & 9 deletions service/stream_export.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"strings"

"github.com/EmissarySocial/emissary/model"
"github.com/EmissarySocial/emissary/tools/counter"
"github.com/EmissarySocial/emissary/tools/ffmpeg"
"github.com/benpate/derp"
"github.com/benpate/rosetta/list"
"github.com/benpate/rosetta/mapof"
Expand Down Expand Up @@ -110,6 +112,7 @@ func (service *Stream) ExportZip(writer *zip.Writer, parent *model.Stream, strea
filename = filename.PushTail(strings.TrimPrefix(filespec.Extension, "."))

// Map attachment metadata
metadata := mapof.NewString()
if pipeline.NotEmpty() {

inSchema := schema.New(schema.Object{
Expand All @@ -126,18 +129,16 @@ func (service *Stream) ExportZip(writer *zip.Writer, parent *model.Stream, strea
outSchema := schema.New(schema.Object{
Wildcard: schema.String{},
})
outObject := mapof.NewString()

if err := pipeline.Execute(inSchema, inObject, outSchema, &outObject); err != nil {
if err := pipeline.Execute(inSchema, inObject, outSchema, &metadata); err != nil {
return derp.Wrap(err, location, "Error processing metadata")
}

for key, value := range outObject {
filespec.Metadata[key] = value
}
}

// Create a file in the ZIP archive
// Make a pipe to transfer from MediaServer to the Metadata writer
pipeReader, pipeWriter := io.Pipe()

// Create a fileWriter in the ZIP archive
fileHeader := zip.FileHeader{
Name: filename.String(),
Method: zip.Store,
Expand All @@ -150,8 +151,19 @@ func (service *Stream) ExportZip(writer *zip.Writer, parent *model.Stream, strea
}

// Write the file into the ZIP archive
if err := service.mediaserver.Get(filespec, fileWriter); err != nil {
return derp.Wrap(err, location, "Error getting attachment")
// Using separate goroutine to avoid deadlock between pipe reader/writer
go func() {

defer pipeWriter.Close()

if err := service.mediaserver.Get(filespec, pipeWriter); err != nil {
derp.Report(derp.Wrap(err, location, "Error getting attachment"))
}
}()

// Add metadata and write to archive
if err := ffmpeg.SetMetadata(pipeReader, filespec.MimeType, metadata, fileWriter); err != nil {
return derp.Wrap(err, location, "Error setting metadata")
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions tools/counter/counter.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package counter

import "github.com/benpate/rosetta/mapof"

// Counter tallies the number of times a key has been added
type Counter mapof.Int

// NewCounter returns a fully initialized Counter object.
// Counters are used to tally the number of times a key has been added.
func NewCounter() Counter {
return make(Counter)
}
Expand Down
15 changes: 15 additions & 0 deletions tools/ffmpeg/ffmpeg.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package ffmpeg

import "os/exec"

var isFFmpegInstalled = false

func init() {

// Check to see if ffmpeg is installed
_, err := exec.LookPath("ffmpeg")

if err == nil {
isFFmpegInstalled = true
}
}
142 changes: 142 additions & 0 deletions tools/ffmpeg/metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package ffmpeg

import (
"bytes"
"io"
"os/exec"
"strings"

"github.com/benpate/derp"
"github.com/rs/zerolog/log"
)

// Some info on FFmpeg metadata
// https://gist.github.com/eyecatchup/0757b3d8b989fe433979db2ea7d95a01
// https://jmesb.com/how_to/create_id3_tags_using_ffmpeg
// https://wiki.multimedia.cx/index.php?title=FFmpeg_Metadata

// How to include album art...
// https://www.bannerbear.com/blog/how-to-add-a-cover-art-to-audio-files-using-ffmpeg/

// SetMetadata uses FFmpeg to apply additional metadata to a file.
// It supports all standard FFmpeg metadata, and adds a special case for cover art. Use "cover" to
// provide the URL of a cover art image
func SetMetadata(input io.Reader, mimeType string, metadata map[string]string, output io.Writer) error {

const location = "ffmpeg.SetMetadata"

// RULE: If FFmpeg is not installed, then break
if !isFFmpegInstalled {
return derp.NewInternalError(location, "FFmpeg is not installed")
}

// RULE: If there is no metadata to set, then just copy the input to the output
if len(metadata) == 0 {
if _, err := io.Copy(output, input); err != nil {
return derp.Wrap(err, location, "No metadata to set", "Error copying input to output")
}
return nil
}

// Let's assemble the arguments we're going to send to FFmpeg
var errors bytes.Buffer
args := make([]string, 0)

// Just some sugar to append to arguments list
add := func(values ...string) {
args = append(args, values...)
}

add("-f", ffmpegFormat(mimeType)) // specify input format because it can't be deduced from a pipe
add("-i", "pipe:0") // read the input from stdin

// Special case for cover art
if cover := metadata["cover"]; cover != "" {
add("-i", cover+".jpg?width=300&height=300") // read the cover art from a URL
add("-map", "0:a") // Map audio into the output file
add("-map", "1:v") // Map cover art into the output file
add("-c:v", "copy") // use the original codec without change
add("-metadata:s:v", "title=Album Cover") // Label the image so that readers will recognize it
add("-metadata:s:v", "comment=Cover (front)") // Label the image so that readers will recognize it
}

// Add all other metadata fields
for key, value := range metadata {

switch key {
case "cover": // NOOP. Already handled above
default:
value = strings.ReplaceAll(value, "\n", `\n`)
add("-metadata", key+"="+value)
}
}

// add("-write_id3v1", "1") // write v1 tags if possible
// add("-id3v2_version", "4") // write v2.4 tags if possible
add("-f", ffmpegFormat(mimeType)) // specify the same format for the output (because it can't be deduced from a pipe)
add("-c:a", "copy") // use the original codec without change
add("-flush_packets", "0") // wait for max size before writing: https://stackoverflow.com/questions/54620528/metadata-in-mp3-not-working-when-piping-from-ffmpeg-with-album-art
add("pipe:1") // write output to the output writer

log.Trace().Msg("ffmpeg " + strings.Join(args, " "))

// Set up the FFmpeg command
command := exec.Command("ffmpeg", args...)
command.Stdin = input
command.Stdout = output
command.Stderr = &errors

// Execute FFmpeg command
if err := command.Run(); err != nil {
return derp.Wrap(err, location, "Error running FFmpeg", errors.String(), args)
}

// UwU
return nil
}

/*
// downloadImage loads an image from a URL and returns the local filename and a pointer to the file
func downloadImage(url string) (string, error) {
const location = "ffmpeg.downloadImage"
log.Trace().Str("url", url).Msg("Downloading image")
// Create a temp file for the image
tempDir := os.TempDir()
tempFilename := strings.ReplaceAll(url, "/", "_")
tempFile, err := os.CreateTemp(tempDir, tempFilename)
if err != nil {
return "", derp.ReportAndReturn(derp.Wrap(err, location, "Error creating temporary file", url))
}
defer tempFile.Close()
// Load the image from the URL
txn := remote.Get(url).Result(tempFile)
if err := txn.Send(); err != nil {
return "", derp.ReportAndReturn(derp.Wrap(err, location, "Error downloading image", url))
}
log.Trace().Str("filename", tempFilename).Msg("Image received successfully")
return tempDir + tempFilename, nil
}
*/

func ffmpegFormat(mimeType string) string {
switch mimeType {
case "audio/mpeg":
return "mp3"
case "audio/ogg":
return "ogg"
case "audio/flac":
return "flac"
case "audio/mp4":
return "mp4"
default:
return "mp3"
}
}

0 comments on commit 70b2ffc

Please sign in to comment.