458 lines
14 KiB
Go
458 lines
14 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"log"
|
||
"log/slog"
|
||
"os"
|
||
"os/exec"
|
||
"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/particle"
|
||
"github.com/flowy-live/llink/internal/speech"
|
||
"github.com/flowy-live/llink/internal/utils"
|
||
"google.golang.org/grpc/codes"
|
||
"google.golang.org/grpc/status"
|
||
|
||
"cloud.google.com/go/firestore"
|
||
"cloud.google.com/go/storage"
|
||
)
|
||
|
||
func createClient(ctx context.Context) *firestore.Client {
|
||
projectId := utils.MustGetEnv("GCP_PROJECT")
|
||
|
||
client, err := firestore.NewClient(ctx, projectId)
|
||
if err != nil {
|
||
log.Fatalf("Failed to create client: %v", err)
|
||
}
|
||
return client
|
||
}
|
||
|
||
// The purpose of the particle processor worker is to listen for new particles
|
||
// across all streams and perform side effects such as
|
||
// - generate transcript if the particle is of type media
|
||
// - send mobile notifications if a client is offline
|
||
// - update the parent stream's `last_child_created_at`
|
||
// - generate vector embedding
|
||
// - synthesize and decide whether ai should generate a particle as a response
|
||
func main() {
|
||
ctx := context.Background()
|
||
|
||
db.Init()
|
||
defer db.Cleanup()
|
||
processingRepo := particle.NewProcessingRepository(db.Pool())
|
||
billingSvc := billing.NewServiceForWorker(db.Pool())
|
||
|
||
storageClient, err := storage.NewClient(ctx)
|
||
if err != nil {
|
||
slog.Error("failed to create GCS client", "error", err)
|
||
os.Exit(1)
|
||
}
|
||
defer storageClient.Close()
|
||
|
||
gcsBucket := utils.MustGetEnv("GCS_BUCKET")
|
||
depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
|
||
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
|
||
BucketName: gcsBucket,
|
||
})
|
||
|
||
speechSvc := speech.NewSpeechService(ctx)
|
||
|
||
client := createClient(ctx)
|
||
defer client.Close()
|
||
|
||
cutoff := time.Now().Add(-5 * time.Minute)
|
||
it := client.CollectionGroup("children").
|
||
Where("created_at", ">", cutoff).
|
||
Snapshots(ctx)
|
||
|
||
for {
|
||
snap, err := it.Next()
|
||
if e := status.Code(err); e == codes.DeadlineExceeded || e == codes.Canceled {
|
||
panic(fmt.Errorf("error: %w", err))
|
||
}
|
||
if err != nil {
|
||
slog.Error("error in processing snapshot", "error", err)
|
||
continue
|
||
}
|
||
|
||
if snap == nil {
|
||
continue
|
||
}
|
||
|
||
for _, change := range snap.Changes {
|
||
if change.Kind != firestore.DocumentAdded {
|
||
continue
|
||
}
|
||
|
||
particleID := change.Doc.Ref.ID
|
||
|
||
processed, err := processingRepo.IsProcessed(ctx, particleID)
|
||
if err != nil {
|
||
slog.Error("failed to check processing status", "particleID", particleID, "error", err)
|
||
continue
|
||
}
|
||
if processed {
|
||
slog.Debug("skipping already processed particle", "particleID", particleID)
|
||
continue
|
||
}
|
||
|
||
slog.Debug("processing particle", "particleID", particleID, "data", change.Doc.Data())
|
||
|
||
// --- Perform side effects ---
|
||
|
||
updateParentLastChildCreatedAt(ctx, change.Doc)
|
||
transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc)
|
||
transcodeMediaParticle(ctx, depotSvc, change.Doc)
|
||
recordFreemiumUsage(ctx, billingSvc, change.Doc)
|
||
|
||
if err := processingRepo.MarkProcessed(ctx, particleID); err != nil {
|
||
slog.Error("failed to mark particle as processed", "particleID", particleID, "error", err)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func transcribeMediaParticle(ctx context.Context, depotSvc depot.Service, speechSvc speech.SpeechService, doc *firestore.DocumentSnapshot) {
|
||
var mediaParticle particle.FirestoreMediaParticle
|
||
err := doc.DataTo(&mediaParticle)
|
||
if err != nil {
|
||
slog.Error("unable to marshal particle data", "error", err)
|
||
return
|
||
}
|
||
|
||
particleType, err := particle.ParseParticleType(mediaParticle.Type)
|
||
if err != nil {
|
||
slog.Error("invalid particle type", "error", err)
|
||
return
|
||
}
|
||
|
||
if particleType != particle.TypeMedia {
|
||
slog.Info("received a particle of type", "particle type", particleType)
|
||
return
|
||
}
|
||
|
||
downloadURL, err := depotSvc.GetDownloadURL(ctx, mediaParticle.Properties.ObjectId)
|
||
if err != nil {
|
||
slog.Error("failed to get download URL", "error", err, "object_id", mediaParticle.Properties.ObjectId)
|
||
return
|
||
}
|
||
|
||
result, err := speechSvc.Transcribe(ctx, downloadURL)
|
||
if err != nil {
|
||
slog.Error("failed to transcribe media", "error", err, "particleID", doc.Ref.ID)
|
||
return
|
||
}
|
||
|
||
transcript := toFirestoreTranscript(result)
|
||
|
||
_, err = doc.Ref.Set(ctx, map[string]interface{}{
|
||
"properties": map[string]interface{}{
|
||
"transcript": transcript,
|
||
},
|
||
}, firestore.MergeAll)
|
||
if err != nil {
|
||
slog.Error("failed to update transcript in firestore", "error", err, "particleID", doc.Ref.ID)
|
||
return
|
||
}
|
||
|
||
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 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
|
||
}
|
||
|
||
isAudio := strings.HasPrefix(mediaParticle.Properties.MimeType, "audio/")
|
||
var outputExt, outputMime string
|
||
if isAudio {
|
||
outputExt = ".m4a"
|
||
outputMime = "audio/mp4"
|
||
} else {
|
||
outputExt = ".mp4"
|
||
outputMime = "video/mp4"
|
||
}
|
||
|
||
tmp, err := os.CreateTemp("", "transcode-*"+outputExt)
|
||
if err != nil {
|
||
slog.Error("transcode: failed to create temp file", "error", err)
|
||
return
|
||
}
|
||
tmpPath := tmp.Name()
|
||
tmp.Close()
|
||
defer os.Remove(tmpPath)
|
||
|
||
transcodeCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||
defer cancel()
|
||
|
||
var args []string
|
||
if isAudio {
|
||
args = []string{
|
||
"-y", "-i", sourceURL,
|
||
"-vn",
|
||
"-c:a", "aac", "-b:a", "128k",
|
||
"-movflags", "+faststart",
|
||
tmpPath,
|
||
}
|
||
} else {
|
||
// Cap encoder parallelism and lookahead to keep memory bounded — screen
|
||
// recordings come in at native display resolution (often 1440p–4K) and
|
||
// libx264's per-thread lookahead/reference buffers blow past the worker's
|
||
// memory limit otherwise. Output is also downscaled to 1080p max, which
|
||
// mobile playback won't notice; the original WebM stays in GCS untouched.
|
||
args = []string{
|
||
"-y", "-i", sourceURL,
|
||
"-vf", "scale='min(1920,iw)':-2:flags=lanczos",
|
||
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
|
||
"-pix_fmt", "yuv420p", "-profile:v", "baseline", "-level", "3.1",
|
||
"-x264-params", "rc-lookahead=20:ref=2",
|
||
"-threads", "2", "-filter_threads", "2",
|
||
"-c:a", "aac", "-b:a", "128k",
|
||
"-movflags", "+faststart",
|
||
tmpPath,
|
||
}
|
||
}
|
||
|
||
cmd := exec.CommandContext(transcodeCtx, "ffmpeg", args...)
|
||
var stderr bytes.Buffer
|
||
cmd.Stderr = &stderr
|
||
if err := cmd.Run(); err != nil {
|
||
slog.Error("transcode: ffmpeg failed", "error", err, "stderr", stderr.String(), "particleID", doc.Ref.ID)
|
||
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
|
||
}
|
||
|
||
f, err := os.Open(tmpPath)
|
||
if err != nil {
|
||
slog.Error("transcode: failed to open transcoded file", "error", err, "path", tmpPath)
|
||
return
|
||
}
|
||
defer f.Close()
|
||
|
||
newObj, err := depotSvc.CreateFromReader(ctx, depot.CreateFromReaderInput{
|
||
Prefix: networkID,
|
||
Name: "transcoded" + outputExt,
|
||
ContentType: outputMime,
|
||
}, 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": outputMime,
|
||
},
|
||
}, 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", outputMime)
|
||
}
|
||
|
||
func isIOSPlayableMime(mime string) bool {
|
||
switch mime {
|
||
case "video/mp4", "video/quicktime", "audio/mp4", "audio/aac", "audio/x-m4a", "audio/mpeg":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func toFirestoreTranscript(result *speech.TranscriptResult) particle.FirestoreTranscript {
|
||
words := make([]particle.FirestoreTranscriptWord, len(result.Words))
|
||
for i, w := range result.Words {
|
||
words[i] = particle.FirestoreTranscriptWord{
|
||
Word: w.Word,
|
||
Start: w.Start,
|
||
End: w.End,
|
||
}
|
||
}
|
||
|
||
paragraphs := make([]particle.FirestoreTranscriptParagraph, len(result.Paragraphs))
|
||
for i, p := range result.Paragraphs {
|
||
sentences := make([]particle.FirestoreTranscriptSentence, len(p.Sentences))
|
||
for j, s := range p.Sentences {
|
||
sentences[j] = particle.FirestoreTranscriptSentence{
|
||
Text: s.Text,
|
||
Start: s.Start,
|
||
End: s.End,
|
||
}
|
||
}
|
||
paragraphs[i] = particle.FirestoreTranscriptParagraph{
|
||
Sentences: sentences,
|
||
Start: p.Start,
|
||
End: p.End,
|
||
}
|
||
}
|
||
|
||
return particle.FirestoreTranscript{
|
||
Transcript: result.Transcript,
|
||
Words: words,
|
||
Paragraphs: paragraphs,
|
||
}
|
||
}
|
||
|
||
// recordFreemiumUsage bumps the network's daily message counter for non-container
|
||
// particles. Idempotent via the surrounding processed_particles guard: the worker
|
||
// only reaches this path on first-seen particles, so a crash/restart won't
|
||
// double-count.
|
||
func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *firestore.DocumentSnapshot) {
|
||
rawType, err := doc.DataAt("type")
|
||
if err != nil {
|
||
slog.Error("failed to read particle type", "error", err, "particleID", doc.Ref.ID)
|
||
return
|
||
}
|
||
typeStr, ok := rawType.(string)
|
||
if !ok {
|
||
slog.Error("particle type is not a string", "particleID", doc.Ref.ID, "type", rawType)
|
||
return
|
||
}
|
||
particleType, err := particle.ParseParticleType(typeStr)
|
||
if err != nil {
|
||
slog.Error("invalid particle type", "error", err, "particleID", doc.Ref.ID)
|
||
return
|
||
}
|
||
// Containers (stream/folder) don't count as "messages" for the daily cap.
|
||
if particleType == particle.TypeStream || particleType == particle.TypeFolder {
|
||
return
|
||
}
|
||
|
||
networkID, err := networkIDFromParticlePath(doc.Ref.Path)
|
||
if err != nil {
|
||
slog.Error("failed to derive network id", "error", err, "path", doc.Ref.Path)
|
||
return
|
||
}
|
||
|
||
if err := billingSvc.IncrementDailyUsage(ctx, networkID, doc.CreateTime); err != nil {
|
||
slog.Error("failed to increment daily usage", "error", err, "networkID", networkID, "particleID", doc.Ref.ID)
|
||
}
|
||
}
|
||
|
||
// 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).
|
||
func updateParentLastChildCreatedAt(ctx context.Context, doc *firestore.DocumentSnapshot) {
|
||
parentChildrenCollectionRef := doc.Ref.Parent
|
||
if parentChildrenCollectionRef == nil {
|
||
return
|
||
}
|
||
|
||
parentParticleDocRef := parentChildrenCollectionRef.Parent
|
||
if parentParticleDocRef == nil {
|
||
slog.Error("particle has no parent document", "particleID", doc.Ref.ID)
|
||
return
|
||
}
|
||
|
||
parentParticleDoc, err := parentParticleDocRef.Get(ctx)
|
||
if err != nil {
|
||
slog.Error("failed to get parent particle", "error", err)
|
||
return
|
||
}
|
||
|
||
slog.Info("parent particle is", "parent particle id", parentParticleDoc.Ref.ID)
|
||
|
||
var streamParticle particle.FirestoreStreamParticle
|
||
if err := parentParticleDoc.DataTo(&streamParticle); err != nil {
|
||
slog.Error("failed to parse stream particle", "error", err)
|
||
return
|
||
}
|
||
|
||
particleType, err := particle.ParseParticleType(streamParticle.Type)
|
||
if err != nil {
|
||
slog.Error("invalid particle type", "error", err)
|
||
return
|
||
}
|
||
|
||
if particleType != particle.TypeStream {
|
||
return
|
||
}
|
||
|
||
// Read the child's created_at — this is the same value that playback markers store
|
||
childCreatedAt, err := doc.DataAt("created_at")
|
||
if err != nil {
|
||
slog.Error("failed to read child created_at", "error", err, "particleID", doc.Ref.ID)
|
||
return
|
||
}
|
||
|
||
slog.Info("going to update the last_child_created_at for parent particle")
|
||
_, err = parentParticleDocRef.Update(ctx, []firestore.Update{
|
||
{
|
||
Path: "last_child_created_at",
|
||
Value: childCreatedAt,
|
||
},
|
||
})
|
||
if err != nil {
|
||
slog.Error("unable to update parent particle `last_child_created_at`")
|
||
}
|
||
}
|