Files
llink/go/internal/particle/transcode.go
T
Arjun Patel 563c91e7d5 infra: add logging wrapping
Resolves issues with gcp cloud logging quirks such as field names
2026-05-27 15:49:27 -07:00

143 lines
5.3 KiB
Go

package particle
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/flowy-live/llink/internal/utils/flog"
"cloud.google.com/go/firestore"
"github.com/flowy-live/llink/internal/depot"
"github.com/flowy-live/llink/internal/media"
)
const (
SkipReasonNotMedia = "not_media"
SkipReasonAlreadyTranscoded = "already_transcoded"
SkipReasonIOSPlayable = "ios_playable"
)
// TranscodeResult reports the outcome of a single Transcode call. Skipped is
// true for any of the three idempotency short-circuits (with SkipReason set);
// Err is non-nil for real failures (download URL, ffmpeg, GCS upload, Firestore
// write). On success both flags are zero-value and TranscodedObjectID +
// OutputMimeType are populated.
type TranscodeResult struct {
Skipped bool
SkipReason string
TranscodedObjectID string
OutputMimeType string
Err error
}
// Transcode produces an iOS-playable MP4/m4a derivative for media particles
// whose original mime type AVPlayer can't decode (notably the WebM the desktop
// recorder emits today). Skips when the particle is not of type media, when a
// transcoded variant has already been written, or when the source is already
// in an iOS-playable family.
//
// ffmpeg reads directly from the GCS signed URL and writes to a local temp
// file — `+faststart` requires seekable output, so a stdout pipe wouldn't
// work. The temp file is then streamed to GCS via depotSvc.CreateFromReader.
func Transcode(ctx context.Context, depotSvc depot.Service, doc *firestore.DocumentSnapshot) TranscodeResult {
var mp FirestoreMediaParticle
if err := doc.DataTo(&mp); err != nil {
flog.Error("transcode: unable to marshal particle data", "error", err)
return TranscodeResult{Err: fmt.Errorf("unmarshal particle: %w", err)}
}
particleType, err := ParseParticleType(mp.Type)
if err != nil {
flog.Error("transcode: invalid particle type", "error", err)
return TranscodeResult{Err: fmt.Errorf("parse type: %w", err)}
}
if particleType != TypeMedia {
flog.Info("transcode: particle is not of type media")
return TranscodeResult{Skipped: true, SkipReason: SkipReasonNotMedia}
}
if mp.Properties.TranscodedObjectId != "" {
return TranscodeResult{Skipped: true, SkipReason: SkipReasonAlreadyTranscoded}
}
if media.IsIOSPlayableMime(mp.Properties.MimeType) {
flog.Info("transcode: skipping because already playable on ios")
return TranscodeResult{Skipped: true, SkipReason: SkipReasonIOSPlayable}
}
sourceURL, err := depotSvc.GetDownloadURL(ctx, mp.Properties.ObjectId)
if err != nil {
flog.Error("transcode: failed to get download URL", "error", err, "object_id", mp.Properties.ObjectId)
return TranscodeResult{Err: fmt.Errorf("download URL: %w", err)}
}
networkID, err := NetworkIDFromParticlePath(doc.Ref.Path)
if err != nil {
flog.Error("transcode: failed to derive network id", "error", err, "path", doc.Ref.Path)
return TranscodeResult{Err: fmt.Errorf("network id: %w", err)}
}
transcodeCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
transcodeOutput, err := media.TranscodeToMp4(transcodeCtx, media.TranscodeInput{
SourceURL: sourceURL,
MimeType: mp.Properties.MimeType,
})
if err != nil {
flog.Error("transcode: ffmpeg failed", "error", err)
return TranscodeResult{Err: fmt.Errorf("ffmpeg: %w", err)}
}
defer os.Remove(transcodeOutput.TempLocalFilePath)
f, err := os.Open(transcodeOutput.TempLocalFilePath)
if err != nil {
flog.Error("transcode: failed to open transcoded file", "error", err, "path", transcodeOutput.TempLocalFilePath)
return TranscodeResult{Err: fmt.Errorf("open temp file: %w", err)}
}
defer f.Close()
newObj, err := depotSvc.CreateFromReader(ctx, depot.CreateFromReaderInput{
Prefix: networkID,
Name: "transcoded" + transcodeOutput.OutputExt,
ContentType: transcodeOutput.OutputMimeType,
}, f)
if err != nil {
flog.Error("transcode: failed to upload transcoded object", "error", err, "particleID", doc.Ref.ID)
return TranscodeResult{Err: fmt.Errorf("upload: %w", err)}
}
if _, err := doc.Ref.Set(ctx, map[string]interface{}{
"properties": map[string]interface{}{
"transcoded_object_id": newObj.ID,
"transcoded_mime_type": transcodeOutput.OutputMimeType,
},
}, firestore.MergeAll); err != nil {
flog.Error("transcode: failed to update particle in firestore", "error", err, "particleID", doc.Ref.ID)
return TranscodeResult{Err: fmt.Errorf("firestore update: %w", err)}
}
flog.Info("transcoded media particle", "particleID", doc.Ref.ID, "transcoded_object_id", newObj.ID, "mime", transcodeOutput.OutputMimeType)
return TranscodeResult{
TranscodedObjectID: newObj.ID,
OutputMimeType: transcodeOutput.OutputMimeType,
}
}
// NetworkIDFromParticlePath extracts the network id from a Firestore particle
// document path. Particles live at `networks/{network_id}/children/.../children/{id}`
// at arbitrary nesting depth, so the network id is always the segment directly
// following the "networks" collection name in the full resource path.
func NetworkIDFromParticlePath(path string) (string, error) {
segments := strings.Split(path, "/")
for i, seg := range segments {
if seg == "networks" && i+1 < len(segments) {
return segments[i+1], nil
}
}
return "", fmt.Errorf("no networks segment in path: %s", path)
}