@@ -0,0 +1,140 @@
|
||||
package particle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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 {
|
||||
slog.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 {
|
||||
slog.Error("transcode: invalid particle type", "error", err)
|
||||
return TranscodeResult{Err: fmt.Errorf("parse type: %w", err)}
|
||||
}
|
||||
if particleType != TypeMedia {
|
||||
slog.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) {
|
||||
slog.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 {
|
||||
slog.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 {
|
||||
slog.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 {
|
||||
slog.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 {
|
||||
slog.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 {
|
||||
slog.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 {
|
||||
slog.Error("transcode: failed to update particle in firestore", "error", err, "particleID", doc.Ref.ID)
|
||||
return TranscodeResult{Err: fmt.Errorf("firestore update: %w", err)}
|
||||
}
|
||||
|
||||
slog.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)
|
||||
}
|
||||
Reference in New Issue
Block a user