| 1 | import { VIDEO_FILE_EXTENSIONS, videoMimeType } from "@t3tools/shared/video"; |
| 2 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 3 | import * as Context from "effect/Context"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Path from "effect/Path"; |
| 8 | import * as Schema from "effect/Schema"; |
| 9 | import * as Scope from "effect/Scope"; |
| 10 | import * as Stream from "effect/Stream"; |
| 11 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 12 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 13 | import * as Electron from "electron"; |
| 14 | |
| 15 | export class DesktopVideoError extends Schema.TaggedError<DesktopVideoError>()( |
| 16 | "DesktopVideoError", |
| 17 | { |
| 18 | stage: Schema.Literals(["unsupported", "source", "download", "open"]), |
| 19 | cause: Schema.optionalKey(Schema.Defect()), |
| 20 | }, |
| 21 | ) { |
| 22 | override get message() { |
| 23 | switch (this.stage) { |
| 24 | case "unsupported": |
| 25 | return "QuickTime is available only on macOS."; |
| 26 | case "source": |
| 27 | return "This video cannot be opened in QuickTime. Try downloading it."; |
| 28 | case "download": |
| 29 | return "The video could not be downloaded. Reconnect and try again."; |
| 30 | case "open": |
| 31 | return "QuickTime could not open the video. Try downloading it."; |
| 32 | } |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | export class DesktopVideo extends Context.Service< |
| 37 | DesktopVideo, |
| 38 | { |
| 39 | readonly open: (input: { |
| 40 | readonly url: string; |
| 41 | readonly name: string; |
| 42 | }) => Effect.Effect<void, DesktopVideoError>; |
| 43 | } |
| 44 | >()("@t3tools/desktop/media/DesktopVideo") {} |
| 45 | |
| 46 | const make = Effect.gen(function* () { |
| 47 | const fs = yield* FileSystem.FileSystem; |
| 48 | const path = yield* Path.Path; |
| 49 | const platform = yield* HostProcess.Platform; |
| 50 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 51 | const scope = yield* Effect.scope; |
| 52 | |
| 53 | return DesktopVideo.of({ |
| 54 | open: Effect.fn("DesktopVideo.open")(function* (input) { |
| 55 | if (platform !== "darwin") return yield* new DesktopVideoError({ stage: "unsupported" }); |
| 56 | const url = yield* Effect.try({ |
| 57 | try: () => new URL(input.url), |
| 58 | catch: (cause) => new DesktopVideoError({ stage: "source", cause }), |
| 59 | }); |
| 60 | const name = Array.from(path.basename(input.name.replaceAll("\\", "/"))) |
| 61 | .filter((character) => character.charCodeAt(0) >= 32 && character !== "\x7f") |
| 62 | .join(""); |
| 63 | if ( |
| 64 | !["https:", "http:"].includes(url.protocol) || |
| 65 | url.username || |
| 66 | url.password || |
| 67 | !name || |
| 68 | name.length > 255 || |
| 69 | (path.extname(name) !== "" && !videoMimeType({ name, mimeType: "" })) |
| 70 | ) |
| 71 | return yield* new DesktopVideoError({ stage: "source" }); |
| 72 | |
| 73 | const response = yield* Effect.tryPromise({ |
| 74 | try: (signal) => Electron.net.fetch(url.href, { signal }), |
| 75 | catch: (cause) => new DesktopVideoError({ stage: "download", cause }), |
| 76 | }); |
| 77 | const body = response.body; |
| 78 | const mimeType = response.headers.get("content-type") ?? ""; |
| 79 | if (!response.ok || !body || !videoMimeType({ name, mimeType })) { |
| 80 | yield* Effect.promise(() => body?.cancel().catch(() => undefined) ?? Promise.resolve()); |
| 81 | return yield* new DesktopVideoError({ stage: "download" }); |
| 82 | } |
| 83 | |
| 84 | const extension = |
| 85 | path.extname(name) || |
| 86 | `.${ |
| 87 | VIDEO_FILE_EXTENSIONS.find( |
| 88 | (extension) => |
| 89 | videoMimeType({ name: `video.${extension}`, mimeType: "" }) === |
| 90 | mimeType.split(";", 1)[0]?.trim().toLowerCase(), |
| 91 | ) ?? "mp4" |
| 92 | }`; |
| 93 | const fileName = path.extname(name) ? name : `${name.slice(0, 240)}${extension}`; |
| 94 | // QuickTime keeps reading after this action returns. |
| 95 | const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-video-" }).pipe( |
| 96 | Effect.provideService(Scope.Scope, scope), |
| 97 | Effect.mapError((cause) => new DesktopVideoError({ stage: "download", cause })), |
| 98 | ); |
| 99 | const file = path.join(directory, fileName); |
| 100 | yield* Stream.fromReadableStream({ |
| 101 | evaluate: () => body, |
| 102 | onError: (cause) => new DesktopVideoError({ stage: "download", cause }), |
| 103 | }).pipe( |
| 104 | Stream.run(fs.sink(file, { flag: "wx", mode: 0o600 })), |
| 105 | Effect.mapError((cause) => new DesktopVideoError({ stage: "download", cause })), |
| 106 | Effect.onError(() => fs.remove(file, { force: true }).pipe(Effect.ignore)), |
| 107 | ); |
| 108 | const exitCode = yield* spawner |
| 109 | .exitCode( |
| 110 | ChildProcess.make( |
| 111 | "/usr/bin/open", |
| 112 | ["-a", "/System/Applications/QuickTime Player.app", file], |
| 113 | { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, |
| 114 | ), |
| 115 | ) |
| 116 | .pipe(Effect.mapError((cause) => new DesktopVideoError({ stage: "open", cause }))); |
| 117 | if (exitCode !== 0) return yield* new DesktopVideoError({ stage: "open" }); |
| 118 | }), |
| 119 | }); |
| 120 | }); |
| 121 | |
| 122 | export const layer = Layer.effect(DesktopVideo, make); |