@lunarity/nebula-fetch-cli 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1 +1,56 @@
1
- # nebula-fetch-cli
1
+ # Nebula Fetch CLI
2
+
3
+ A command-line interface tool for downloading media from different platforms.
4
+
5
+ ## 🚀 Installation
6
+
7
+ ```bash
8
+ npm install -g @lunarity/nebula-fetch-cli
9
+ ```
10
+
11
+ ## 📖 Usage
12
+
13
+ ### 📺 YouTube Downloads
14
+
15
+ Download videos or audio from YouTube using the following commands:
16
+
17
+ ```bash
18
+ # Download video (default)
19
+ nebula-fetch youtube <url>
20
+ # or using alias
21
+ nebula-fetch yt <url>
22
+
23
+ # Download audio only
24
+ nebula-fetch youtube <url> --audio
25
+ # or
26
+ nebula-fetch yt <url> -a
27
+
28
+ # Specify output path
29
+ nebula-fetch youtube <url> --output path/to/file
30
+ # or
31
+ nebula-fetch yt <url> -o path/to/file
32
+
33
+ # Show verbose output
34
+ nebula-fetch youtube <url> --verbose
35
+ # or
36
+ nebula-fetch yt <url> -v
37
+ ```
38
+
39
+ ### ⚙️ Options
40
+
41
+ - `-o, --output <path>` - Specify the output path for the downloaded file
42
+ - `-a, --audio` - Download audio only (saves as .mp3)
43
+ - `-v, --verbose` - Show detailed information during download
44
+ - `--version` - Show version number
45
+ - `--help` - Show help information
46
+
47
+ ## ✨ Features
48
+
49
+ - YouTube video downloads
50
+ - Audio-only extraction option
51
+ - Custom output path
52
+ - Verbose mode for detailed information
53
+
54
+ ## 📄 License
55
+
56
+ MIT
@@ -4,36 +4,48 @@ import path from "path";
4
4
  import { YtdlCore, toPipeableStream } from "@ybd-project/ytdl-core";
5
5
  import chalk from "chalk";
6
6
 
7
- export async function downloadYoutubeAudio(
8
- url: string,
9
- outputPath?: string
10
- ): Promise<void> {
11
- try {
12
- if (!url) {
13
- throw new Error("Please provide a YouTube URL");
14
- }
7
+ interface DownloadOptions {
8
+ url: string;
9
+ audioOnly?: boolean;
10
+ verbose?: boolean;
11
+ outputPath?: string;
12
+ }
15
13
 
14
+ export async function downloadYoutube(options: DownloadOptions): Promise<void> {
15
+ const { url, audioOnly = false, verbose = false, outputPath } = options;
16
+
17
+ try {
16
18
  console.log(chalk.cyan("🔍 Fetching video information..."));
17
19
  const ytdl = new YtdlCore({});
18
20
  let videoTitle = "";
19
21
 
20
22
  await ytdl.getBasicInfo(url).then((info) => {
23
+ console.log(chalk.cyan(`🎬 Video title: ${info.videoDetails.title}`));
24
+ console.log(
25
+ chalk.cyan(
26
+ `🎬 Video author: ${info.videoDetails.author?.name || "Unknown"}`
27
+ )
28
+ );
29
+ if (verbose) {
30
+ console.log(JSON.stringify(info.videoDetails, null, 2));
31
+ }
21
32
  videoTitle = info.videoDetails.title.replace(/[^\w\s]/gi, "_");
22
33
  });
23
34
 
24
35
  const stream = await ytdl.download(url, {
25
- filter: "audioonly",
36
+ filter: audioOnly ? "audioonly" : "videoandaudio",
37
+ quality: "highest",
26
38
  });
27
39
 
28
40
  const outputFilePath =
29
- outputPath || path.join(process.cwd(), `${videoTitle}.mp3`);
41
+ outputPath ||
42
+ path.join(process.cwd(), `${videoTitle}.${audioOnly ? "mp3" : "mp4"}`);
30
43
  toPipeableStream(stream).pipe(fs.createWriteStream(outputFilePath));
31
44
 
32
- console.log(chalk.green(`✅ Download finished: ${outputFilePath}`));
45
+ console.log(chalk.green.bold(`✅ Download finished: ${outputFilePath}`));
33
46
  } catch (error) {
34
47
  if (error instanceof Error) {
35
- console.error(chalk.red("Error:", error.message));
48
+ console.error(chalk.red.bold("Error:", error.message));
36
49
  }
37
- throw error;
38
50
  }
39
51
  }
package/dist/index.js CHANGED
@@ -2928,22 +2928,22 @@ var require_diagnostics = __commonJS((exports, module) => {
2928
2928
  const debuglog = fetchDebuglog.enabled ? fetchDebuglog : undiciDebugLog;
2929
2929
  diagnosticsChannel.channel("undici:client:beforeConnect").subscribe((evt) => {
2930
2930
  const {
2931
- connectParams: { version, protocol, port, host }
2931
+ connectParams: { version: version2, protocol, port, host }
2932
2932
  } = evt;
2933
- debuglog("connecting to %s using %s%s", `${host}${port ? `:${port}` : ""}`, protocol, version);
2933
+ debuglog("connecting to %s using %s%s", `${host}${port ? `:${port}` : ""}`, protocol, version2);
2934
2934
  });
2935
2935
  diagnosticsChannel.channel("undici:client:connected").subscribe((evt) => {
2936
2936
  const {
2937
- connectParams: { version, protocol, port, host }
2937
+ connectParams: { version: version2, protocol, port, host }
2938
2938
  } = evt;
2939
- debuglog("connected to %s using %s%s", `${host}${port ? `:${port}` : ""}`, protocol, version);
2939
+ debuglog("connected to %s using %s%s", `${host}${port ? `:${port}` : ""}`, protocol, version2);
2940
2940
  });
2941
2941
  diagnosticsChannel.channel("undici:client:connectError").subscribe((evt) => {
2942
2942
  const {
2943
- connectParams: { version, protocol, port, host },
2943
+ connectParams: { version: version2, protocol, port, host },
2944
2944
  error
2945
2945
  } = evt;
2946
- debuglog("connection to %s using %s%s errored - %s", `${host}${port ? `:${port}` : ""}`, protocol, version, error.message);
2946
+ debuglog("connection to %s using %s%s errored - %s", `${host}${port ? `:${port}` : ""}`, protocol, version2, error.message);
2947
2947
  });
2948
2948
  diagnosticsChannel.channel("undici:client:sendHeaders").subscribe((evt) => {
2949
2949
  const {
@@ -2978,22 +2978,22 @@ var require_diagnostics = __commonJS((exports, module) => {
2978
2978
  const debuglog = undiciDebugLog.enabled ? undiciDebugLog : websocketDebuglog;
2979
2979
  diagnosticsChannel.channel("undici:client:beforeConnect").subscribe((evt) => {
2980
2980
  const {
2981
- connectParams: { version, protocol, port, host }
2981
+ connectParams: { version: version2, protocol, port, host }
2982
2982
  } = evt;
2983
- debuglog("connecting to %s%s using %s%s", host, port ? `:${port}` : "", protocol, version);
2983
+ debuglog("connecting to %s%s using %s%s", host, port ? `:${port}` : "", protocol, version2);
2984
2984
  });
2985
2985
  diagnosticsChannel.channel("undici:client:connected").subscribe((evt) => {
2986
2986
  const {
2987
- connectParams: { version, protocol, port, host }
2987
+ connectParams: { version: version2, protocol, port, host }
2988
2988
  } = evt;
2989
- debuglog("connected to %s%s using %s%s", host, port ? `:${port}` : "", protocol, version);
2989
+ debuglog("connected to %s%s using %s%s", host, port ? `:${port}` : "", protocol, version2);
2990
2990
  });
2991
2991
  diagnosticsChannel.channel("undici:client:connectError").subscribe((evt) => {
2992
2992
  const {
2993
- connectParams: { version, protocol, port, host },
2993
+ connectParams: { version: version2, protocol, port, host },
2994
2994
  error
2995
2995
  } = evt;
2996
- debuglog("connection to %s%s using %s%s errored - %s", host, port ? `:${port}` : "", protocol, version, error.message);
2996
+ debuglog("connection to %s%s using %s%s errored - %s", host, port ? `:${port}` : "", protocol, version2, error.message);
2997
2997
  });
2998
2998
  diagnosticsChannel.channel("undici:client:sendHeaders").subscribe((evt) => {
2999
2999
  const {
@@ -25208,10 +25208,10 @@ var require_acorn = __commonJS((exports, module) => {
25208
25208
  }
25209
25209
  return this.finishToken(type, word);
25210
25210
  };
25211
- var version = "8.14.0";
25211
+ var version2 = "8.14.0";
25212
25212
  Parser.acorn = {
25213
25213
  Parser,
25214
- version,
25214
+ version: version2,
25215
25215
  defaultOptions,
25216
25216
  Position,
25217
25217
  SourceLocation,
@@ -25260,7 +25260,7 @@ var require_acorn = __commonJS((exports, module) => {
25260
25260
  exports2.tokContexts = types;
25261
25261
  exports2.tokTypes = types$1;
25262
25262
  exports2.tokenizer = tokenizer;
25263
- exports2.version = version;
25263
+ exports2.version = version2;
25264
25264
  });
25265
25265
  });
25266
25266
 
@@ -50243,7 +50243,7 @@ var require_nwsapi = __commonJS((exports, module) => {
50243
50243
  global2.NW.Dom = factory(global2, Export);
50244
50244
  }
50245
50245
  })(exports, function Factory(global2, Export) {
50246
- var version = "nwsapi-2.2.16", doc = global2.document, root = doc.documentElement, slice = Array.prototype.slice, HSP = "[\\x20\\t]", VSP = "[\\r\\n\\f]", WSP = "[\\x20\\t\\r\\n\\f]", CFG = {
50246
+ var version2 = "nwsapi-2.2.16", doc = global2.document, root = doc.documentElement, slice = Array.prototype.slice, HSP = "[\\x20\\t]", VSP = "[\\r\\n\\f]", WSP = "[\\x20\\t\\r\\n\\f]", CFG = {
50247
50247
  operators: "[~*^$|]=|=",
50248
50248
  combinators: "[\\x20\\t>+~](?=[^>+~])"
50249
50249
  }, NOT = {
@@ -51490,7 +51490,7 @@ var require_nwsapi = __commonJS((exports, module) => {
51490
51490
  emit,
51491
51491
  Config,
51492
51492
  Snapshot,
51493
- Version: version,
51493
+ Version: version2,
51494
51494
  install,
51495
51495
  uninstall,
51496
51496
  Operators,
@@ -55018,9 +55018,9 @@ var require_saxes = __commonJS((exports) => {
55018
55018
  }
55019
55019
  }
55020
55020
  }
55021
- setXMLVersion(version) {
55022
- this.currentXMLVersion = version;
55023
- if (version === "1.0") {
55021
+ setXMLVersion(version2) {
55022
+ this.currentXMLVersion = version2;
55023
+ if (version2 === "1.0") {
55024
55024
  this.isChar = isChar10;
55025
55025
  this.getCode = this.getCode10;
55026
55026
  } else {
@@ -55491,12 +55491,12 @@ var require_saxes = __commonJS((exports) => {
55491
55491
  switch (this.name) {
55492
55492
  case "version": {
55493
55493
  this.xmlDeclExpects = ["encoding", "standalone"];
55494
- const version = value;
55495
- this.xmlDecl.version = version;
55496
- if (!/^1\.[0-9]+$/.test(version)) {
55494
+ const version2 = value;
55495
+ this.xmlDecl.version = version2;
55496
+ if (!/^1\.[0-9]+$/.test(version2)) {
55497
55497
  this.fail("version number must match /^1\\.[0-9]+$/.");
55498
55498
  } else if (!this.opt.forceXMLVersion) {
55499
- this.setXMLVersion(version);
55499
+ this.setXMLVersion(version2);
55500
55500
  }
55501
55501
  break;
55502
55502
  }
@@ -125844,10 +125844,10 @@ var require_supports_color = __commonJS((exports, module) => {
125844
125844
  return 3;
125845
125845
  }
125846
125846
  if ("TERM_PROGRAM" in env2) {
125847
- const version = parseInt((env2.TERM_PROGRAM_VERSION || "").split(".")[0], 10);
125847
+ const version2 = parseInt((env2.TERM_PROGRAM_VERSION || "").split(".")[0], 10);
125848
125848
  switch (env2.TERM_PROGRAM) {
125849
125849
  case "iTerm.app":
125850
- return version >= 3 ? 3 : 2;
125850
+ return version2 >= 3 ? 3 : 2;
125851
125851
  case "Apple_Terminal":
125852
125852
  return 2;
125853
125853
  }
@@ -140758,7 +140758,7 @@ var require_websocket_server = __commonJS((exports, module) => {
140758
140758
  socket.on("error", socketOnError);
140759
140759
  const key = req.headers["sec-websocket-key"];
140760
140760
  const upgrade = req.headers.upgrade;
140761
- const version = +req.headers["sec-websocket-version"];
140761
+ const version2 = +req.headers["sec-websocket-version"];
140762
140762
  if (req.method !== "GET") {
140763
140763
  const message = "Invalid HTTP method";
140764
140764
  abortHandshakeOrEmitwsClientError(this, req, socket, 405, message);
@@ -140774,7 +140774,7 @@ var require_websocket_server = __commonJS((exports, module) => {
140774
140774
  abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
140775
140775
  return;
140776
140776
  }
140777
- if (version !== 8 && version !== 13) {
140777
+ if (version2 !== 8 && version2 !== 13) {
140778
140778
  const message = "Missing or invalid Sec-WebSocket-Version header";
140779
140779
  abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
140780
140780
  return;
@@ -140812,7 +140812,7 @@ var require_websocket_server = __commonJS((exports, module) => {
140812
140812
  }
140813
140813
  if (this.options.verifyClient) {
140814
140814
  const info = {
140815
- origin: req.headers[`${version === 8 ? "sec-websocket-origin" : "origin"}`],
140815
+ origin: req.headers[`${version2 === 8 ? "sec-websocket-origin" : "origin"}`],
140816
140816
  secure: !!(req.socket.authorized || req.socket.encrypted),
140817
140817
  req
140818
140818
  };
@@ -151183,6 +151183,8 @@ var {
151183
151183
  Option,
151184
151184
  Help
151185
151185
  } = import__.default;
151186
+ // package.json
151187
+ var version = "0.0.3";
151186
151188
 
151187
151189
  // node_modules/chalk/source/vendor/ansi-styles/index.js
151188
151190
  var ANSI_BACKGROUND_OFFSET = 10;
@@ -151453,10 +151455,10 @@ function _supportsColor(haveStream, { streamIsTTY, sniffFlags = true } = {}) {
151453
151455
  return 3;
151454
151456
  }
151455
151457
  if ("TERM_PROGRAM" in env) {
151456
- const version = Number.parseInt((env.TERM_PROGRAM_VERSION || "").split(".")[0], 10);
151458
+ const version2 = Number.parseInt((env.TERM_PROGRAM_VERSION || "").split(".")[0], 10);
151457
151459
  switch (env.TERM_PROGRAM) {
151458
151460
  case "iTerm.app": {
151459
- return version >= 3 ? 3 : 2;
151461
+ return version2 >= 3 ? 3 : 2;
151460
151462
  }
151461
151463
  case "Apple_Terminal": {
151462
151464
  return 2;
@@ -151668,41 +151670,86 @@ var source_default = chalk;
151668
151670
  var import_ytdl_core = __toESM(require_Default(), 1);
151669
151671
  import fs from "fs";
151670
151672
  import path from "path";
151671
- async function downloadYoutubeAudio(url, outputPath) {
151673
+ async function downloadYoutube(options) {
151674
+ const { url, audioOnly = false, verbose = false, outputPath } = options;
151672
151675
  try {
151673
- if (!url) {
151674
- throw new Error("Please provide a YouTube URL");
151675
- }
151676
151676
  console.log(source_default.cyan("\uD83D\uDD0D Fetching video information..."));
151677
151677
  const ytdl = new import_ytdl_core.YtdlCore({});
151678
151678
  let videoTitle = "";
151679
151679
  await ytdl.getBasicInfo(url).then((info) => {
151680
+ console.log(source_default.cyan(`\uD83C\uDFAC Video title: ${info.videoDetails.title}`));
151681
+ console.log(source_default.cyan(`\uD83C\uDFAC Video author: ${info.videoDetails.author?.name || "Unknown"}`));
151682
+ if (verbose) {
151683
+ console.log(JSON.stringify(info.videoDetails, null, 2));
151684
+ }
151680
151685
  videoTitle = info.videoDetails.title.replace(/[^\w\s]/gi, "_");
151681
151686
  });
151682
151687
  const stream2 = await ytdl.download(url, {
151683
- filter: "audioonly"
151688
+ filter: audioOnly ? "audioonly" : "videoandaudio",
151689
+ quality: "highest"
151684
151690
  });
151685
- const outputFilePath = outputPath || path.join(process.cwd(), `${videoTitle}.mp3`);
151691
+ const outputFilePath = outputPath || path.join(process.cwd(), `${videoTitle}.${audioOnly ? "mp3" : "mp4"}`);
151686
151692
  import_ytdl_core.toPipeableStream(stream2).pipe(fs.createWriteStream(outputFilePath));
151687
- console.log(source_default.green(`\u2705 Download finished: ${outputFilePath}`));
151693
+ console.log(source_default.green.bold(`\u2705 Download finished: ${outputFilePath}`));
151688
151694
  } catch (error) {
151689
151695
  if (error instanceof Error) {
151690
- console.error(source_default.red("Error:", error.message));
151696
+ console.error(source_default.red.bold("\u274C Error:", error.message));
151697
+ }
151698
+ }
151699
+ }
151700
+
151701
+ // utils/validators.ts
151702
+ function isValidYoutubeUrl(url) {
151703
+ try {
151704
+ const urlObj = new URL(url);
151705
+ const validDomains = [
151706
+ "youtube.com",
151707
+ "www.youtube.com",
151708
+ "m.youtube.com",
151709
+ "youtu.be"
151710
+ ];
151711
+ if (!validDomains.includes(urlObj.hostname)) {
151712
+ return false;
151691
151713
  }
151692
- throw error;
151714
+ if (urlObj.hostname === "youtu.be") {
151715
+ return urlObj.pathname.length > 1;
151716
+ }
151717
+ if (urlObj.pathname === "/watch") {
151718
+ const videoId = urlObj.searchParams.get("v");
151719
+ return !!videoId && videoId.length === 11;
151720
+ }
151721
+ if (urlObj.pathname.startsWith("/embed/")) {
151722
+ const videoId = urlObj.pathname.split("/")[2];
151723
+ return !!videoId && videoId.length === 11;
151724
+ }
151725
+ return false;
151726
+ } catch {
151727
+ return false;
151693
151728
  }
151694
151729
  }
151695
151730
 
151696
151731
  // index.ts
151697
151732
  var program2 = new Command;
151698
- program2.name("nebula-fetch").description("CLI tool for downloading media from different platforms").version("1.0.0");
151699
- program2.command("youtube").description("Download a video from YouTube").argument("<url>", "URL of the video").option("-o, --output <path>", "Output path for the video").action(async (url, options) => {
151733
+ program2.name("nebula-fetch").description("CLI tool for downloading media from different platforms").version(version);
151734
+ program2.command("youtube").alias("yt").description("Download a video from YouTube").argument("<url>", "URL of the video").option("-o, --output <path>", "Output path for the video").option("-a, --audio", "Download only the audio", false).option("-v, --verbose", "Show verbose output", false).action(async (url, options) => {
151700
151735
  try {
151701
- console.log(source_default.blue(`Downloading video from: ${url}`));
151702
- await downloadYoutubeAudio(url, options.output);
151736
+ if (!isValidYoutubeUrl(url)) {
151737
+ console.error(source_default.red("Error: Invalid YouTube URL. Please provide a valid YouTube video URL"));
151738
+ process.exit(1);
151739
+ }
151740
+ console.log(source_default.blue(`\uD83D\uDE80 Downloading ${options.audio ? "audio" : "video"} from: ${url}`));
151741
+ await downloadYoutube({
151742
+ url,
151743
+ audioOnly: options.audio,
151744
+ outputPath: options.output,
151745
+ verbose: options.verbose
151746
+ });
151703
151747
  } catch (error) {
151704
151748
  console.error(source_default.red("Error:"), error);
151705
151749
  process.exit(1);
151706
151750
  }
151707
151751
  });
151708
- program2.parse();
151752
+ program2.parseAsync().catch((error) => {
151753
+ console.error(source_default.red("Fatal error:"), error);
151754
+ process.exit(1);
151755
+ });
package/index.ts CHANGED
@@ -1,29 +1,57 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { Command } from "commander";
4
+ import { version } from "@/package.json";
5
+
4
6
  import chalk from "chalk";
5
- import { downloadYoutubeAudio } from "./commands/youtube";
7
+
8
+ import { downloadYoutube } from "@/commands/youtube";
9
+ import { isValidYoutubeUrl } from "./utils/validators";
6
10
 
7
11
  const program = new Command();
8
12
 
9
13
  program
10
14
  .name("nebula-fetch")
11
15
  .description("CLI tool for downloading media from different platforms")
12
- .version("1.0.0");
16
+ .version(version);
13
17
 
14
18
  program
15
19
  .command("youtube")
20
+ .alias("yt")
16
21
  .description("Download a video from YouTube")
17
22
  .argument("<url>", "URL of the video")
18
23
  .option("-o, --output <path>", "Output path for the video")
24
+ .option("-a, --audio", "Download only the audio", false)
25
+ .option("-v, --verbose", "Show verbose output", false)
19
26
  .action(async (url, options) => {
20
27
  try {
21
- console.log(chalk.blue(`Downloading video from: ${url}`));
22
- await downloadYoutubeAudio(url, options.output);
28
+ if (!isValidYoutubeUrl(url)) {
29
+ console.error(
30
+ chalk.red(
31
+ "Error: Invalid YouTube URL. Please provide a valid YouTube video URL"
32
+ )
33
+ );
34
+ process.exit(1);
35
+ }
36
+
37
+ console.log(
38
+ chalk.blue(
39
+ `🚀 Downloading ${options.audio ? "audio" : "video"} from: ${url}`
40
+ )
41
+ );
42
+ await downloadYoutube({
43
+ url,
44
+ audioOnly: options.audio,
45
+ outputPath: options.output,
46
+ verbose: options.verbose,
47
+ });
23
48
  } catch (error) {
24
49
  console.error(chalk.red("Error:"), error);
25
50
  process.exit(1);
26
51
  }
27
52
  });
28
53
 
29
- program.parse();
54
+ program.parseAsync().catch((error) => {
55
+ console.error(chalk.red("Fatal error:"), error);
56
+ process.exit(1);
57
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunarity/nebula-fetch-cli",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "module": "index.ts",
5
5
  "description": "CLI tool for downloading media from different platforms",
6
6
  "type": "module",
@@ -27,6 +27,13 @@
27
27
  "keywords": [
28
28
  "cli",
29
29
  "media",
30
- "download"
31
- ]
30
+ "download",
31
+ "youtube"
32
+ ],
33
+ "author": "Krzysztof Oszczapiński",
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/LUNARITYai/nebula-fetch-cli.git"
38
+ }
32
39
  }
package/tsconfig.json CHANGED
@@ -22,6 +22,11 @@
22
22
  // Some stricter flags (disabled by default)
23
23
  "noUnusedLocals": false,
24
24
  "noUnusedParameters": false,
25
- "noPropertyAccessFromIndexSignature": false
25
+ "noPropertyAccessFromIndexSignature": false,
26
+
27
+ "baseUrl": ".",
28
+ "paths": {
29
+ "@/*": ["./*"]
30
+ }
26
31
  }
27
32
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Validates YouTube URLs in various formats:
3
+ * - Standard: https://www.youtube.com/watch?v=VIDEO_ID
4
+ * - Short: https://youtu.be/VIDEO_ID
5
+ * - Embedded: https://www.youtube.com/embed/VIDEO_ID
6
+ * - Mobile: https://m.youtube.com/watch?v=VIDEO_ID
7
+ * - With timestamps: https://www.youtube.com/watch?v=VIDEO_ID&t=123s
8
+ */
9
+ export function isValidYoutubeUrl(url: string): boolean {
10
+ try {
11
+ const urlObj = new URL(url);
12
+
13
+ // Check for valid YouTube domains
14
+ const validDomains = [
15
+ "youtube.com",
16
+ "www.youtube.com",
17
+ "m.youtube.com",
18
+ "youtu.be",
19
+ ];
20
+ if (!validDomains.includes(urlObj.hostname)) {
21
+ return false;
22
+ }
23
+
24
+ // Handle youtu.be format
25
+ if (urlObj.hostname === "youtu.be") {
26
+ return urlObj.pathname.length > 1; // Must have video ID after /
27
+ }
28
+
29
+ // Handle standard YouTube URLs
30
+ if (urlObj.pathname === "/watch") {
31
+ const videoId = urlObj.searchParams.get("v");
32
+ return !!videoId && videoId.length === 11;
33
+ }
34
+
35
+ // Handle embedded format
36
+ if (urlObj.pathname.startsWith("/embed/")) {
37
+ const videoId = urlObj.pathname.split("/")[2];
38
+ return !!videoId && videoId.length === 11;
39
+ }
40
+
41
+ return false;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }