1import { VIDEO_FILE_EXTENSIONS, videoMimeType } from "@t3tools/shared/video";
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9import * as Scope from "effect/Scope";
10import * as Stream from "effect/Stream";
11import * as ChildProcess from "effect/process/ChildProcess";
12import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
13import * as Electron from "electron";
14
15export 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
36export 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
46const 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
122export const layer = Layer.effect(DesktopVideo, make);