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) }