Steven
Steven9 λεπτά ανάγνωσης

Βγάζοντας το backend από τη διαδρομή του upload

Το GeekBye καταγράφει την οθόνη σου και αποθηκεύει το βίντεο στο Google Drive σου. Η πρώτη έκδοση έστελνε κάθε καταγραφή μέσα από τους ίδιους τους διακομιστές του GeekBye στη διαδρομή προς τα εκεί· μια έκδοση αργότερα, το αρχείο πήγαινε κατευθείαν από το μηχάνημά σου στο Drive, και το backend υποβιβάστηκε στο να κρατά έναν μοναδικό δείκτη. Το ενδιαφέρον κομμάτι είναι πόσο λίγο κώδικα περιέχει στην πραγματικότητα η «άμεση, resumable» έκδοση — επειδή η resumability προήλθε από τη διαγραφή ενός proxy, όχι από τη συγγραφή ενός.

Μηχανική
Αρχιτεκτονική
Desktop
Εκδόσεις GeekBye
Βγάζοντας το backend από τη διαδρομή του upload

Το GeekBye μπορεί να καταγράψει την οθόνη σου — βίντεο, ήχο συστήματος, και μικρόφωνο — και να αφήσει την ολοκληρωμένη καταγραφή στο Google Drive σου αυτόματα. Ανάμεσα στο v1.8.11 και το v1.8.13, η περιγραφή της λειτουργίας που βλέπει ο χρήστης ελάχιστα άλλαξε, αλλά η διαδρομή που ακολουθεί το βίντεο για να φτάσει στο Drive αναδρομολογήθηκε εντελώς. Στην πρώτη έκδοση, η καταγραφή σου ταξίδευε μέσα από τους διακομιστές του GeekBye. Στη δεύτερη, πήγαινε κατευθείαν από το μηχάνημά σου στο Drive σου, και το backend του GeekBye δεν είδε ποτέ ούτε ένα byte της. Αυτή η αναδρομολόγηση είναι όλη η ιστορία, και το ικανοποιητικό κομμάτι είναι ότι η «καλύτερη» έκδοση περιέχει εμφανώς λιγότερο κώδικα από αυτήν που αντικατέστησε.

Ο αγωγός, από άκρη σε άκρη

Η καταγραφή συμβαίνει στον renderer. Το ScreenRecordingService.ts δημιουργεί έναν MediaRecorder πάνω από το capture stream με { mimeType: 'video/webm;codecs=vp9,opus', videoBitsPerSecond: 1_000_000 } και τον ξεκινά με ένα timeslice ενός δευτερολέπτου (CHUNK_INTERVAL_MS = 1000). Κάθε ondataavailable blob στέλνεται μέσω IPC στην κύρια διεργασία, όπου ο handler recording:save-chunk το προσαρτά σε ένα αρχείο στον δίσκο με fs.promises.appendFile. Αξίζει να επισημανθεί νωρίς, επειδή είναι κάτι που εύκολα παρερμηνεύεται: αυτός ο ρυθμός του ενός δευτερολέπτου είναι ο ρυθμός εγγραφής στον δίσκο, όχι ένα μέγεθος chunk ανεβάσματος. Μέχρι να ξεκινήσει το ανέβασμα, υπάρχει ένα μοναδικό ολοκληρωμένο αρχείο WebM στον δίσκο.

Αυτό που διαφέρει ανάμεσα στις δύο εκδόσεις είναι μόνο τι συμβαίνει σε αυτό το ολοκληρωμένο αρχείο.

Το πριν: τα πάντα μέσα από το backend (v1.8.11)

Η διαδρομή ανεβάσματος του v1.8.11, στο CloudUploader.processRecording(), είναι ο προφανής σχεδιασμός και μια απολύτως λογική πρώτη έκδοση:

  1. POST /api/geekbye/recordings για τη δημιουργία μιας εγγραφής backend με τα μεταδεδομένα της καταγραφής.
  2. Διάβασε ολόκληρο το βίντεο στη μνήμη και κάνε POST στο backend ως multipart — const fileBuffer = fs.readFileSync(filePath) — χτυπώντας το POST /api/geekbye/recordings/${id}/upload. Το backend μετά ανεβάζει αυτό το αρχείο στο Drive και επιστρέφει ένα driveUrl.
  3. POST …/process για να σταλεί το transcript για ανάλυση AI.

Η φέρουσα λεπτομέρεια είναι το βήμα 2: ο client φορτώνει ολόκληρη την καταγραφή στη μνήμη και κάθε byte της διέρχεται από τους ίδιους τους διακομιστές του GeekBye στη διαδρομή προς το Drive. Για ένα σύντομο κλιπ αυτό είναι εντάξει. Για μια μεγάλη καταγραφή οθόνης είναι τρία προβλήματα ταυτόχρονα — πίεση μνήμης στον client από το readFileSync, κόστος εύρους ζώνης και αναμετάδοσης στο backend για ένα αρχείο που ούτε καν κρατά, και ένα μοναδικό μονολιθικό POST που πρέπει να ξεκινήσει από το μηδέν αν το δίκτυο τρεμοπαίξει στα μισά.

Αξίζει επίσης να είμαστε ειλικρινείς ότι αυτό το «πριν» δεν ήταν κάποιος άσπιλος αρχικός σχεδιασμός. Η λειτουργία του v1.8.11 συμπίεσε μαζί ένα pivot εντός εβδομάδας: μια πρώιμη εκδοχή ανέβαζε στο Cloudflare R2 μέσω pre-signed URLs και μετέτρεπε το WebM σε MP4 με ένα ενσωματωμένο ffmpeg-static· αυτό αντικαταστάθηκε από το Drive flow με διαμεσολάβηση του backend· και μετά η μετατροπή ffmpeg αφαιρέθηκε εντελώς υπέρ του ανεβάσματος του WebM ως έχει (το commit σημειώνει ότι κατέβασε το bitrate και παρήγαγε αρχεία περίπου τέσσερις φορές μικρότερα — μια εκτίμηση inline, όχι benchmark). Έτσι ακόμα και το «πριν» είχε ήδη μάθει μια φορά να διαγράφει μια native εξάρτηση. Η επόμενη έκδοση θα διέγραφε και το proxy.

Το μετά: κατευθείαν στο Drive (v1.8.13)

Το v1.8.13 ξαναγράφει το CloudUploader.processRecording() γύρω από μία πρόταση από το commit που το κυκλοφόρησε: ανέβασε κατευθείαν στο Drive, "keeping backend for metadata record + AI transcript analysis only." Η νέα διαδρομή:

  1. POST /api/geekbye/recordingsμόνο μεταδεδομένα (τίτλος, διάρκεια, μέγεθος αρχείου, format: 'webm'). Το σχόλιο στον κώδικα είναι ξεκάθαρο: // Create backend record (metadata only — no file upload).
  2. Ανέβασε το βίντεο απευθείας στο Google Drive, παρακάμπτοντας εντελώς τον μεσάζοντα backend.
  3. PATCH /api/geekbye/recordings/${backendRecordingId} με το προκύπτον { driveUrl, driveFolderId } — στην εγγραφή backend λέγεται πού κατέληξε το αρχείο.
  4. Το transcript εξακολουθεί να πηγαίνει στο …/process για ανάλυση.

Το backend πέρασε από το να είναι μέσα στη διαδρομή του αρχείου στο να του λέγεται για το αρχείο εκ των υστέρων. Κρατά μια γραμμή και έναν δείκτη· τα bytes ζουν μόνο στον δίσκο του χρήστη και στο Drive του χρήστη.

Γίνοντας πραγματικός Drive client

Για να μιλήσει ο client απευθείας με το Drive, πρέπει να είναι ένας πραγματικός Google Drive API client, και το v1.8.13 προσθέτει τον μηχανισμό γι' αυτό: ένα νέο DriveService (ο Drive client), ένα νέο DriveAuthRepository που υποστηρίζεται από έναν νέο SQLite πίνακα drive_auth, και το SDK googleapis ως εξάρτηση.

Η εξουσιοδότηση είναι μια καθαρή παράδοση δυνατότητας. Το app-config endpoint του backend αναμεταδίδει τα client credentials του Google OAuth — ένα clientId και ένα clientSecret — προς την εφαρμογή, τα οποία το CloudUploader διαβάζει και περνά στο driveService.initialize(...). Από εκεί η desktop εφαρμογή τρέχει ολόκληρο το OAuth flow μόνη της: στήνει έναν προσωρινό http server σε μια τυχαία 127.0.0.1 θύρα, ανοίγει την οθόνη συγκατάθεσης στον browser του συστήματος με shell.openExternal, πιάνει το redirect σε ένα /callback, και ανταλλάσσει τον κωδικό για tokens με το google-auth-library. Αυτά τα tokens αποθηκεύονται τοπικά — saveDriveAuth(access_token, refresh_token, expiry_date) μέσα στο drive_auth — με το scope drive.file, το οποίο επιτρέπει στην εφαρμογή να διαχειρίζεται μόνο τα αρχεία που δημιουργεί. Η ανανέωση είναι επίσης τοπική: ένας listener oauth2Client.on('tokens', …) γράφει τα ανανεωμένα tokens κατευθείαν πίσω στον πίνακα. Το backend παραδίδει τη δυνατότητα μία φορά και μετά μένει εκτός του βρόχου.

Το «resumable» κομμάτι, με ειλικρίνεια

Να η λεπτομέρεια για την οποία θέλω περισσότερο να είμαι ξεκάθαρος, επειδή είναι ακριβώς το είδος του πράγματος που ένα changelog στρογγυλοποιεί. Η σημείωση έκδοσης λέει "resumable uploads," και αυτό είναι αλήθεια από τη σκοπιά του χρήστη. Αλλά το GeekBye δεν υλοποίησε το resumable πρωτόκολλο. Δεν υπάρχει σταθερά μεγέθους chunk, ούτε tracker byte-offset, ούτε καμία διαχείριση Content-Range / 308 Resume Incomplete πουθενά στον πηγαίο κώδικα του GeekBye. Κάνε grep γι' αυτά και δεν παίρνεις τίποτα — ζουν μέσα στο SDK googleapis.

Αυτό που κάνει ο ίδιος ο κώδικας του GeekBye είναι το εξής:

const fileStats = statSync(videoFilePath)
const videoStream = createReadStream(videoFilePath)

const videoRes = await this.drive.files.create(
  {
    requestBody: { name: `${title}.webm`, parents: [folderId] },
    media: { mimeType: 'video/webm', body: videoStream },
    fields: 'id',
  },
  {
    onUploadProgress: (evt) => {
      const percent = Math.round((evt.bytesRead / fileStats.size) * 100)
      onProgress(percent)
    },
  },
)

Δύο επιλογές κάνουν όλη τη δουλειά. Πρώτον, το media.body είναι ένα stream (createReadStream), όχι ένα buffer — και το να περνάς ένα stream είναι ακριβώς αυτό που κάνει το googleapis να διαπραγματευτεί μια resumable session εκ μέρους σου αντί να κάνει τα πάντα με μία κίνηση. Δεύτερον, η πρόοδος διαβάζεται από το callback onUploadProgress του SDK σε σχέση με το fileStats.size. Αυτή είναι όλη η υλοποίηση του "resumable upload" σε επίπεδο εφαρμογής: stream μέσα, πρόοδος έξω. Το δύσκολο πρωτόκολλο — άνοιξε μια session, ανέβασε σε chunks, συνέχισε από ένα offset μετά από μια πτώση — είναι δουλειά του SDK, και η σωστή μηχανική απόφαση ήταν να το αφήσουμε να κάνει αυτή τη δουλειά αντί να την επανεφεύρουμε.

Πράγμα που αναπλαισιώνει ολόκληρη την έκδοση. Η βελτίωση της αξιοπιστίας δεν ήρθε από τη συγγραφή μιας μηχανής ανεβάσματος. Ήρθε από τη διαγραφή του proxy που στεκόταν μπροστά από μία — και, νωρίτερα την ίδια εβδομάδα, από τη διαγραφή του βήματος ffmpeg μπροστά από εκείνο. Λιγότερος κώδικας, περισσότερη ανθεκτικότητα, επειδή το ανθεκτικό πράγμα ήταν μια πολυδοκιμασμένη βιβλιοθήκη τη στιγμή που σταμάτησες να την ταΐζεις με το χέρι ένα buffer.

Τα κομμάτια που είναι γνήσια του GeekBye

Το να παραδώσεις τη μεταφορά στο SDK δεν σημαίνει ότι δεν έμεινε τίποτα να χτιστεί. Δύο κομμάτια πραγματικής λογικής εφαρμογής το περιβάλλουν.

Ένα rollback για ορφανούς φακέλους. Κάθε καταγραφή παίρνει τον δικό της υποφάκελο Drive (που δημιουργείται με mimeType: 'application/vnd.google-apps.folder'), και το transcript προσγειώνεται εκεί ως δεύτερο αρχείο δίπλα στο βίντεο. Αν το ανέβασμα ρίξει εξαίρεση, το catch διαγράφει αυτόν τον φάκελο — this.drive.files.delete({ fileId: folderId }), καταγεγραμμένο ως Rolled back orphaned Drive folder — ώστε ένα αποτυχημένο ανέβασμα να μην αφήνει ένα ίχνος από άδειους φακέλους στο Drive σου. Το να δημιουργείς έναν container πριν ξέρεις ότι η λειτουργία θα πετύχει είναι μια μικρή υποχρέωση· το να κάνεις τη διαδρομή αποτυχίας να τον καθαρίζει είναι η διόρθωση.

Κύκλος ζωής γύρω από ένα singleton. Το DriveService είναι ένα singleton που κρατά έναν in-memory OAuth client, και αυτός ο in-memory client δεν επιβιώνει μιας επανεκκίνησης της εφαρμογής, παρόλο που τα tokens επιβιώνουν (είναι στη βάση δεδομένων). Έτσι το CloudUploader επαναρχικοποιεί το DriveService από τα αποθηκευμένα tokens πριν από κάθε ανέβασμα, πράγμα που σημαίνει ότι μια καταγραφή που έγινε αμέσως μετά την επανεκκίνηση εξακολουθεί να ανεβαίνει χωρίς να σου ζητά να συνδεθείς ξανά. Και το πέρασμα του review μετέτρεψε τον handler drive-connect σε fire-and-forget, επειδή μια μπλοκαριστική κλήση connect καθυστερούσε το status polling του renderer — το UI δεν μπορούσε να δείξει "connecting…" αν η κλήση connect κρατούσε το thread. Αυτά τα δύο — επαναρχικοποίηση-από-αποθήκευση και μη μπλοκαριστικό connect — είναι οι πραγματικές ακραίες περιπτώσεις της άμεσης διαδρομής, και αξιοσημείωτα κανένα από τα δύο δεν αφορά το chunking ανεβάσματος. Όταν αναθέτεις το δύσκολο κομμάτι, τα bugs που σου μένουν είναι bugs κύκλου ζωής.

Τρία πράγματα που μας έμαθε αυτή η έκδοση

  1. Ρώτα προς τα πού ρέουν τα bytes, όχι απλώς αν δουλεύει. Το proxy του v1.8.11 δούλευε. Αλλά η δρομολόγηση κάθε καταγραφής μέσα από τους δικούς σου διακομιστές σου κοστίζει εύρος ζώνης, μνήμη, και δυνατότητα επανεκκίνησης για ένα αρχείο που δεν κρατάς. Το ξαναχάραγμα της διαδρομής δεδομένων ώστε ο client να μιλά απευθείας με τον προορισμό είναι συχνά ολόκληρη η βελτιστοποίηση.
  2. Το καλύτερο resumable ανέβασμα είναι αυτό που δεν έγραψες. Το να περάσεις ένα stream σε ένα ώριμο SDK αγόρασε μια πλήρη resumable session για δύο γραμμές κώδικα. Το ένστικτο να φτιάξεις στο χέρι το chunking και τα μαθηματικά offset θα είχε παραγάγει περισσότερο κώδικα και λιγότερη αξιοπιστία.
  3. Όταν αναθέτεις τον πυρήνα, κληρονομείς κύκλο ζωής, όχι αλγορίθμους. Τα πραγματικά bugs του άμεσου client ήταν "επαναρχικοποίησε το singleton μετά την επανεκκίνηση" και "μη μπλοκάρεις την κλήση connect," όχι byte offsets. Αυτή είναι μια καλή ανταλλαγή — τα bugs κύκλου ζωής είναι ορατά και τοπικά· τα bugs πρωτοκόλλου δεν είναι κανένα από τα δύο.

Για το προηγούμενο κεφάλαιο της ιστορίας του v1, γεννήθηκε και διορθώθηκε σε δώδεκα λεπτά (v1.8.10)· και για ολόκληρη την πορεία, η ανατομία της παράδοσης λογισμικού στην τελειότητα.

Σχετικά Άρθρα

Τα τρία ρήματα που κρατούν το Web Audio ζωντανό
Steven
Steven9 λεπτά ανάγνωσης

Τα τρία ρήματα που κρατούν το Web Audio ζωντανό

Δύο point releases του GeekBye, με δύο μήνες απόσταση και σε δύο διαφορετικά αρχεία, δίδαξαν στον κώδικα ήχου μας το ίδιο μάθημα από αντίθετα άκρα: σταμάτα να αντιμετωπίζεις το AudioContext του browser ως αναλώσιμο. Μία έκδοση έμαθε να κάνει resume() ένα context που το macOS είχε σιωπηλά suspend στη μέση της καταγραφής· η άλλη έμαθε να κάνει suspend() αντί για close(), ώστε οι διαδοχικές sessions να σταματήσουν να χτυπούν στο ταβάνι των περίπου έξι context του Chromium. Resume, suspend, close — αυτή είναι όλη η πλοκή.

Μηχανική
Audio
Desktop
Ξεχωρίζοντας μια κλήση από μια ανοιχτή εφαρμογή
Steven
Steven9 λεπτά ανάγνωσης

Ξεχωρίζοντας μια κλήση από μια ανοιχτή εφαρμογή

Το GeekBye μπορεί να παρατηρήσει ότι μπήκες σε μια βιντεοκλήση και να σου προσφέρει να την καταγράψει. Η ανίχνευση αποδεικνύεται το εύκολο μισό — ένα Swift binary που διαβάζει τίτλους παραθύρων κάθε δέκα δευτερόλεπτα. Το δύσκολο μισό είναι η ακρίβεια: να μην πυροδοτείται όταν το Zoom είναι απλώς ανοιχτό, να μη ρωτά για μια σύσκεψη που ήδη καταγράφεις, και να μη σιγάζει το μικρόφωνο στην κλήση στην οποία πραγματικά βρίσκεσαι. Τρεις εκδόσεις, και η καθεμία είναι μια δικλείδα που έπρεπε να μάθει να μην νικά τον εαυτό της.

Μηχανική
macOS
Desktop
Γεννήθηκε και διορθώθηκε σε δώδεκα λεπτά
Steven
Steven8 λεπτά ανάγνωσης

Γεννήθηκε και διορθώθηκε σε δώδεκα λεπτά

Το changelog του GeekBye v1.8.10 λέει ότι διόρθωσε ένα crash κατά την επεξεργασία των συντομεύσεων πληκτρολογίου. Όντως το διόρθωσε — αλλά το crash εισήχθη και διορθώθηκε στο ίδιο pull request, με δώδεκα λεπτά διαφορά, και δεν έφτασε ποτέ σε ούτε έναν χρήστη. Η πραγματική ιστορία είναι ο καταρράκτης αξιοπιστίας που το παρήγαγε: μια μικρή, σωστή αλλαγή στο πότε αναστέλλονται οι συντομεύσεις, και το bug αποδόμησης του React που ξεπήδησε από μία γραμμή που προοριζόταν να κάνει τον επεξεργαστή ασφαλέστερο.

Μηχανική
React
Desktop