-
Notifications
You must be signed in to change notification settings - Fork 1
Preserve cover art when concatenating audio files #205
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -152,22 +152,71 @@ | |
| return bitrate, nil | ||
| } | ||
|
|
||
| func (conv *FFMpegMediaProcessor) ExtractCoverArt(filepath string) (coverArtFilePath string, err error) { | ||
| func (conv *FFMpegMediaProcessor) ExtractCoverArt(ctx context.Context, filepath string) (coverArtFilePath string, err error) { | ||
| _, span := otel.Tracer("github.com/dir01/mediary/media_processor").Start(ctx, "media_processor.ExtractCoverArt", | ||
| trace.WithAttributes(attribute.String("filepath", filepath)), | ||
| ) | ||
| defer span.End() | ||
|
|
||
| errCtx := oops.With("filepath", filepath) | ||
| coverArtFilePath = filepath + ".jpg" | ||
| errCtx = errCtx.With("coverArtFilePath", coverArtFilePath) | ||
|
|
||
| cmd := exec.Command("ffmpeg", "-i", filepath, "-map", "0:v", "-map", "-0:V", "-c", "copy", "-y", coverArtFilePath) | ||
| cmd := exec.CommandContext(ctx, "ffmpeg", "-i", filepath, "-map", "0:v", "-map", "-0:V", "-c", "copy", "-y", coverArtFilePath) | ||
| errCtx = errCtx.With("cmd", cmd.String()) | ||
|
|
||
| out, err := cmd.CombinedOutput() | ||
| if err != nil { | ||
| return "", errCtx.With("output", string(out)).Wrapf(err, "failed to run ffmpeg") | ||
| return "", errCtx.With("output", string(out)).Wrapf(err, "failed to extract cover art") | ||
| } | ||
|
|
||
| return coverArtFilePath, nil | ||
| } | ||
|
|
||
| func (conv *FFMpegMediaProcessor) EmbedCoverArt(ctx context.Context, filepath string, coverArtPath string) error { | ||
| _, span := otel.Tracer("github.com/dir01/mediary/media_processor").Start(ctx, "media_processor.EmbedCoverArt", | ||
| trace.WithAttributes( | ||
| attribute.String("filepath", filepath), | ||
| attribute.String("cover_art_path", coverArtPath), | ||
| ), | ||
| ) | ||
| defer span.End() | ||
|
|
||
| errCtx := oops.With("filepath", filepath, "coverArtPath", coverArtPath) | ||
|
|
||
| tag, err := id3v2.Open(filepath, id3v2.Options{Parse: true}) | ||
| if err != nil { | ||
| span.RecordError(err) | ||
| span.SetStatus(codes.Error, err.Error()) | ||
| return errCtx.Wrapf(err, "failed to open file for cover art embedding") | ||
| } | ||
| defer func() { _ = tag.Close() }() | ||
|
|
||
| artwork, err := os.ReadFile(coverArtPath) | ||
| if err != nil { | ||
| span.RecordError(err) | ||
| span.SetStatus(codes.Error, err.Error()) | ||
| return errCtx.Wrapf(err, "failed to read cover art file") | ||
| } | ||
|
|
||
| tag.AddAttachedPicture(id3v2.PictureFrame{ | ||
| Encoding: id3v2.EncodingUTF8, | ||
| MimeType: "image/jpeg", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| PictureType: id3v2.PTFrontCover, | ||
| Description: "Cover", | ||
| Picture: artwork, | ||
| }) | ||
|
|
||
| if err := tag.Save(); err != nil { | ||
| span.RecordError(err) | ||
| span.SetStatus(codes.Error, err.Error()) | ||
| return errCtx.Wrapf(err, "failed to save cover art") | ||
| } | ||
|
|
||
| conv.log.Debug("embedded cover art", slog.String("filepath", filepath), slog.String("coverArtPath", coverArtPath)) | ||
| return nil | ||
| } | ||
|
|
||
| func (conv *FFMpegMediaProcessor) GetDuration(filepath string) (time.Duration, error) { | ||
| cmd := exec.Command( | ||
| "ffprobe", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -101,6 +101,15 @@ func (svc *Service) newConcatenateFlow(jobID string, job *Job) (func(ctx context | |
| fsFilepaths = append(fsFilepaths, filepathsMap[fp]) | ||
| } | ||
|
|
||
| // extract cover art from the first source file before concatenation | ||
| var coverArtPath string | ||
| if artPath, artErr := svc.mediaProcessor.ExtractCoverArt(downloadCtx, fsFilepaths[0]); artErr != nil { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In Useful? React with 👍 / 👎. |
||
| svc.log.Debug("no cover art found in first file, proceeding without", | ||
| append(logAttrs, slog.Any("error", artErr))...) | ||
| } else { | ||
| coverArtPath = artPath | ||
| } | ||
|
|
||
| // collect per-file durations for chapter markers | ||
| var chapters []Chapter | ||
| var offset time.Duration | ||
|
|
@@ -148,6 +157,14 @@ func (svc *Service) newConcatenateFlow(jobID string, job *Job) (func(ctx context | |
| append(logAttrs, slog.Any("error", chapErr))...) | ||
| } | ||
| } | ||
|
|
||
| // re-embed cover art into the concatenated file | ||
| if coverArtPath != "" { | ||
| if embedErr := svc.mediaProcessor.EmbedCoverArt(concatCtx, resultFilepath, coverArtPath); embedErr != nil { | ||
| svc.log.Warn("failed to embed cover art, proceeding without", | ||
| append(logAttrs, slog.Any("error", embedErr))...) | ||
| } | ||
| } | ||
| } | ||
| logAttrs = append(logAttrs, slog.String("localFilename", resultFilepath)) | ||
| errCtx = errCtx.With("localFilename", resultFilepath) | ||
|
|
||
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
Copilot Autofix
AI 27 days ago
General approach: ensure that any filesystem path derived from user input is validated or restricted to a known-safe directory before being used with operations like
os.ReadFile. ForEmbedCoverArt, we should check thatcoverArtPathis an absolute path inside a directory we control (e.g., the same data directory used by the downloader or at least a designated temp/cache directory), or otherwise reject it. Because we only seemedia_processor/media_processor.go, and we don’t know the exact safe directory, the least invasive and still useful fix is to (a) avoid following symlinks when opening the cover art file and (b) optionally perform basic sanity checks on the path (e.g., ensuring it’s not empty, and normalizing it) before use.Best single change without altering behavior too much: replace
os.ReadFile(coverArtPath)with a safer sequence that opens the file usingos.Opencombined withfilepath.Clean, checks for obvious invalid inputs (empty path), and then reads via the file descriptor. This reduces risk from bizarre path strings and makes it easier to extend validation later. It doesn’t change external behavior for valid paths, but breaks clearly malformed ones early. Since we’re constrained to the shown code, we’ll limit ourselves to using the standard library (path/filepath) and keep all call sites intact.Concrete edits:
media_processor/media_processor.go, add an import for"path/filepath".EmbedCoverArt, right before reading the artwork, normalize and minimally validatecoverArtPath, then open the file and read from the handle instead of usingos.ReadFiledirectly.No other files need changes for this particular sink.