39ac3ed290
Closes #192
141 lines
5.2 KiB
Go
141 lines
5.2 KiB
Go
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)
|
|
}
|