@@ -6,13 +6,11 @@ import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/flowy-live/llink/internal/billing"
|
||||
"github.com/flowy-live/llink/internal/db"
|
||||
"github.com/flowy-live/llink/internal/depot"
|
||||
"github.com/flowy-live/llink/internal/media"
|
||||
"github.com/flowy-live/llink/internal/particle"
|
||||
"github.com/flowy-live/llink/internal/speech"
|
||||
"github.com/flowy-live/llink/internal/utils"
|
||||
@@ -109,7 +107,7 @@ func main() {
|
||||
|
||||
updateParentLastChildCreatedAt(ctx, change.Doc)
|
||||
transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc)
|
||||
transcodeMediaParticle(ctx, depotSvc, change.Doc)
|
||||
particle.Transcode(ctx, depotSvc, change.Doc)
|
||||
recordFreemiumUsage(ctx, billingSvc, change.Doc)
|
||||
|
||||
if err := processingRepo.MarkProcessed(ctx, particleID); err != nil {
|
||||
@@ -165,98 +163,6 @@ func transcribeMediaParticle(ctx context.Context, depotSvc depot.Service, speech
|
||||
slog.Info("transcribed media particle", "particleID", doc.Ref.ID)
|
||||
}
|
||||
|
||||
// transcodeMediaParticle 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 source is already in an
|
||||
// iOS-playable family or when a transcoded variant has already been written.
|
||||
//
|
||||
// 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 (no
|
||||
// presigned-PUT round-trip — the worker has direct SDK access).
|
||||
func transcodeMediaParticle(ctx context.Context, depotSvc depot.Service, doc *firestore.DocumentSnapshot) {
|
||||
var mediaParticle particle.FirestoreMediaParticle
|
||||
if err := doc.DataTo(&mediaParticle); err != nil {
|
||||
slog.Error("transcode: unable to marshal particle data", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
particleType, err := particle.ParseParticleType(mediaParticle.Type)
|
||||
if err != nil {
|
||||
slog.Error("transcode: invalid particle type", "error", err)
|
||||
return
|
||||
}
|
||||
if particleType != particle.TypeMedia {
|
||||
slog.Info("transcode: particle is not of type media")
|
||||
return
|
||||
}
|
||||
|
||||
// Already transcoded — re-delivery within the 5-min Firestore window.
|
||||
if mediaParticle.Properties.TranscodedObjectId != "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Source is already iOS-playable; nothing to do.
|
||||
if media.IsIOSPlayableMime(mediaParticle.Properties.MimeType) {
|
||||
slog.Info("transcode: skipping because already playable on ios")
|
||||
return
|
||||
}
|
||||
|
||||
sourceURL, err := depotSvc.GetDownloadURL(ctx, mediaParticle.Properties.ObjectId)
|
||||
if err != nil {
|
||||
slog.Error("transcode: failed to get download URL", "error", err, "object_id", mediaParticle.Properties.ObjectId)
|
||||
return
|
||||
}
|
||||
|
||||
networkID, err := networkIDFromParticlePath(doc.Ref.Path)
|
||||
if err != nil {
|
||||
slog.Error("transcode: failed to derive network id", "error", err, "path", doc.Ref.Path)
|
||||
return
|
||||
}
|
||||
|
||||
transcodeCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
transcodeOutput, err := media.TranscodeToMp4(transcodeCtx, media.TranscodeInput{
|
||||
SourceURL: sourceURL,
|
||||
MimeType: mediaParticle.Properties.MimeType,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("transcode: ffmpeg failed", "error", err)
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
_, err = doc.Ref.Set(ctx, map[string]interface{}{
|
||||
"properties": map[string]interface{}{
|
||||
"transcoded_object_id": newObj.ID,
|
||||
"transcoded_mime_type": transcodeOutput.OutputMimeType,
|
||||
},
|
||||
}, firestore.MergeAll)
|
||||
if err != nil {
|
||||
slog.Error("transcode: failed to update particle in firestore", "error", err, "particleID", doc.Ref.ID)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("transcoded media particle", "particleID", doc.Ref.ID, "transcoded_object_id", newObj.ID, "mime", transcodeOutput.OutputMimeType)
|
||||
}
|
||||
|
||||
func toFirestoreTranscript(result *speech.TranscriptResult) particle.FirestoreTranscript {
|
||||
words := make([]particle.FirestoreTranscriptWord, len(result.Words))
|
||||
for i, w := range result.Words {
|
||||
@@ -316,7 +222,7 @@ func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *f
|
||||
return
|
||||
}
|
||||
|
||||
networkID, err := networkIDFromParticlePath(doc.Ref.Path)
|
||||
networkID, err := particle.NetworkIDFromParticlePath(doc.Ref.Path)
|
||||
if err != nil {
|
||||
slog.Error("failed to derive network id", "error", err, "path", doc.Ref.Path)
|
||||
return
|
||||
@@ -327,23 +233,6 @@ func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *f
|
||||
}
|
||||
}
|
||||
|
||||
// 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 second segment
|
||||
// of the full doc path (which itself is rooted under the Firestore db path:
|
||||
// `projects/.../documents/networks/{network_id}/...`).
|
||||
func networkIDFromParticlePath(path string) (string, error) {
|
||||
// doc.Ref.Path is the full resource path; find the "networks" collection
|
||||
// and return the next segment.
|
||||
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)
|
||||
}
|
||||
|
||||
// updateParentLastChildCreatedAt updates the parent stream's last_child_created_at
|
||||
// to the child's actual created_at timestamp, so it stays directly comparable with
|
||||
// playback markers (which also store child created_at values).
|
||||
|
||||
Reference in New Issue
Block a user