├── rust-toolchain.toml ├── assets ├── mainScreenshot.png └── summaryScreenshot.png ├── .gitignore ├── .cargo └── config.toml ├── Dockerfile ├── Cargo.toml ├── LICENSE ├── testName.js ├── testSubtitles.js ├── src ├── markdown_test.md ├── gemini.rs ├── subtitle.rs └── main.rs ├── README.md ├── flake.lock ├── .github └── workflows │ └── release.yml ├── flake.nix ├── static ├── index.html ├── script.js └── style.css └── Cargo.lock /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly" -------------------------------------------------------------------------------- /assets/mainScreenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Milkshiift/YouTubeTLDR/HEAD/assets/mainScreenshot.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .env 3 | 4 | .idea/ 5 | .vscode/ 6 | 7 | .DS_Store 8 | *.log 9 | 10 | /static/*.gz -------------------------------------------------------------------------------- /assets/summaryScreenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Milkshiift/YouTubeTLDR/HEAD/assets/summaryScreenshot.png -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | rustflags = [ 3 | "-Z", "threads=8", 4 | "-Z", "location-detail=none", 5 | "-C", "link-arg=-Wl,-z,pack-relative-relocs", 6 | "-C", "symbol-mangling-version=v0", 7 | "-C", "link-args=-Wl,--icf=all", 8 | ] -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rustlang/rust:nightly-alpine AS builder 2 | 3 | RUN apk add --no-cache openssl-dev pkgconfig build-base 4 | 5 | RUN cargo new --bin YouTubeTLDR 6 | WORKDIR /YouTubeTLDR 7 | 8 | COPY ./Cargo.toml ./Cargo.toml 9 | COPY ./Cargo.lock ./Cargo.lock 10 | 11 | COPY ./src ./src 12 | COPY ./static ./static 13 | 14 | RUN cargo build --release --no-default-features --features rustls-tls 15 | 16 | FROM alpine:latest 17 | 18 | RUN apk add --no-cache openssl 19 | 20 | COPY --from=builder /YouTubeTLDR/target/release/YouTubeTLDR /usr/local/bin/YouTubeTLDR 21 | 22 | COPY ./static /app/static 23 | 24 | WORKDIR /app 25 | 26 | EXPOSE 8000 27 | 28 | CMD ["/usr/local/bin/YouTubeTLDR"] 29 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | cargo-features = ["trim-paths"] 2 | 3 | [package] 4 | name = "YouTubeTLDR" 5 | version = "1.6.0" 6 | edition = "2024" 7 | readme = "README.md" 8 | license = "MIT" 9 | 10 | [dependencies] 11 | minreq = { git = "https://github.com/Milkshiift/minreq", features = ["proxy"] } 12 | miniserde = { git = "https://github.com/Milkshiift/miniserde" } 13 | flume = "0.11.1" 14 | 15 | [build-dependencies] 16 | minifier = "0.3" 17 | flate2 = "1.1" 18 | 19 | [features] 20 | default = ["native-tls"] 21 | native-tls = ["minreq/https-native"] 22 | rustls-tls = ["minreq/https-rustls"] 23 | 24 | [profile.release] 25 | opt-level = 3 26 | lto = "fat" 27 | codegen-units = 1 28 | panic = "abort" 29 | strip = true 30 | trim-paths = "all" 31 | 32 | [profile.dev] 33 | opt-level = 1 34 | incremental = true 35 | 36 | [lints.clippy] 37 | pedantic = { level = "deny", priority = -1 } 38 | nursery = { level = "deny", priority = -1 } 39 | unwrap_used = "deny" 40 | cast_sign_loss = "allow" 41 | cast_possible_truncation = "allow" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Milkshift 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /testName.js: -------------------------------------------------------------------------------- 1 | const res = await fetch("https://www.youtube.com/watch?v=NjpmTL-ZM8E"); 2 | const html = await res.text(); 3 | 4 | function getYouTubeVideoTitle(htmlString) { 5 | const metaTitleStartTag = ''; 7 | 8 | const startIndex = htmlString.indexOf(metaTitleStartTag); 9 | 10 | // If the meta title tag is not found, return null or an empty string 11 | if (startIndex === -1) { 12 | return null; 13 | } 14 | 15 | // Calculate the starting index of the actual title content 16 | const contentStartIndex = startIndex + metaTitleStartTag.length; 17 | 18 | // Find the end index of the title content (the closing double quote) 19 | const endIndex = htmlString.indexOf(metaTitleEndTag, contentStartIndex); 20 | 21 | // If the closing quote is not found (which shouldn't happen with valid HTML), return null 22 | if (endIndex === -1) { 23 | return null; 24 | } 25 | 26 | // Extract the substring between the start of the content and the closing quote 27 | return htmlString.substring(contentStartIndex, endIndex); 28 | } 29 | 30 | console.log(getYouTubeVideoTitle(html)); -------------------------------------------------------------------------------- /testSubtitles.js: -------------------------------------------------------------------------------- 1 | function decodeXml(xml) { 2 | return xml.replace(/&/g, '&') 3 | .replace(//g, '>') 5 | .replace(/"/g, '"') 6 | .replace(/'/g, "'"); 7 | } 8 | 9 | async function getYoutubeTranscript(videoId, language = "en") { 10 | const videoUrl = `https://www.youtube.com/watch?v=${videoId}`; 11 | 12 | const html = await fetch(videoUrl).then(res => res.text()); 13 | const apiKeyMatch = html.match(/"INNERTUBE_API_KEY":"([^"]+)"/); 14 | if (!apiKeyMatch) throw new Error("INNERTUBE_API_KEY not found."); 15 | const apiKey = apiKeyMatch[1]; 16 | 17 | const playerData = await fetch(`https://www.youtube.com/youtubei/v1/player?key=${apiKey}`, { 18 | method: "POST", 19 | headers: { "Content-Type": "application/json" }, 20 | body: JSON.stringify({ 21 | context: { 22 | client: { 23 | clientName: "WEB", 24 | clientVersion: "2.20240401.01.00" 25 | } 26 | }, 27 | videoId 28 | }) 29 | }).then(res => res.json()); 30 | 31 | console.log(playerData); 32 | 33 | const tracks = playerData?.captions?.playerCaptionsTracklistRenderer?.captionTracks; 34 | if (!tracks) throw new Error("No captions found."); 35 | const track = tracks.find(t => t.languageCode === language); 36 | if (!track) throw new Error(`No captions for language: ${language}`); 37 | 38 | const baseUrl = track.baseUrl.replace(/&fmt=\w+$/, ""); 39 | 40 | const xml = await fetch(baseUrl).then(res => res.text()); 41 | 42 | const transcript = []; 43 | const regex = /(.+?)<\/text>/g; 44 | const matches = xml.matchAll(regex); 45 | 46 | for (const match of matches) { 47 | const start = parseFloat(match[1]); 48 | const duration = parseFloat(match[2]); 49 | const caption = decodeXml(match[3]); 50 | 51 | transcript.push({ 52 | caption, 53 | startTime: start, 54 | endTime: start + duration 55 | }); 56 | } 57 | 58 | return transcript; 59 | } 60 | 61 | console.log(await getYoutubeTranscript("X9BblS3qGaU", "en")); -------------------------------------------------------------------------------- /src/markdown_test.md: -------------------------------------------------------------------------------- 1 | # Markdown Test 2 | 3 | ## 1. Text Formatting 4 | 5 | This is *italic text* and this is _also italic_. 6 | This is **bold text** and this is __also bold__. 7 | This is ***bold and italic*** and this is ___also bold and italic___. 8 | This is ~~strikethrough text~~. 9 | This is `inline code`. 10 | 11 | Here's a paragraph with a [link to Google](https://www.google.com). 12 | 13 | ## 2. Headings 14 | 15 | # Heading 1 16 | ## Heading 2 17 | ### Heading 3 18 | #### Heading 4 19 | ##### Heading 5 20 | ###### Heading 6 21 | 22 | ## 3. Lists 23 | 24 | ### Unordered List 25 | 26 | * Item 1 27 | * Sub-item A 28 | * Sub-item B 29 | * Item 2 30 | * Sub-item C 31 | * Deep sub-item i 32 | * Deep sub-item ii 33 | * Item 3 34 | 35 | ### Ordered List 36 | 37 | 1. First item 38 | 2. Second item 39 | 1. Nested ordered item 1 40 | 2. Nested ordered item 2 41 | 3. Third item 42 | * Mixed unordered item A 43 | * Mixed unordered item B 44 | 45 | ## 4. Blockquotes 46 | 47 | > This is a blockquote. 48 | > It can span multiple lines. 49 | > 50 | > > Nested blockquote! 51 | > 52 | > Back to the first level. 53 | 54 | ## 5. Code Blocks 55 | 56 | ### Inline Code 57 | 58 | Here's some `print("hello world")` in Python. 59 | 60 | ### Fenced Code Block 61 | 62 | ```python 63 | def factorial(n): 64 | if n == 0: 65 | return 1 66 | else: 67 | return n * factorial(n-1) 68 | 69 | print(factorial(5)) 70 | ``` 71 | 72 | ### Indented Code Block 73 | 74 | This is an indented code block. 75 | It's typically indented by 4 spaces or 1 tab. 76 | Looks like pre-formatted text. 77 | 78 | ## 6. Tables 79 | 80 | | Header 1 | Header 2 | Header 3 | 81 | |:---------|:--------:|---------:| 82 | | Left | Center | Right | 83 | | Cell A | Cell B | Cell C | 84 | | 123 | 456 | 789 | 85 | 86 | ## 7. Horizontal Rules 87 | 88 | --- 89 | 90 | A line above this. 91 | 92 | *** 93 | 94 | Another line above this. 95 | 96 | ___ 97 | 98 | And one more line above this. 99 | 100 | ## 8. Task Lists (GitHub Flavored Markdown) 101 | 102 | - [x] Task 1 (completed) 103 | - [ ] Task 2 (pending) 104 | - [x] Subtask A (completed) 105 | - [ ] Subtask B (pending) 106 | - [ ] Task 3 (pending) 107 | 108 | ## 9. Backslash Escapes 109 | 110 | \* Not an italic \* 111 | \_ Not an italic \_ 112 | \` Not inline code \` 113 | \# Not a heading \# 114 | \[ Not a link \[ 115 | 116 | ## 10. Definition Lists (Markdown Extra / Pandoc) 117 | 118 | Term 1 119 | : Definition of term 1. 120 | 121 | Term 2 122 | : Definition of term 2, line 1. 123 | : Definition of term 2, line 2. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎬 YouTubeTLDR 2 | 3 | ![Rust](https://img.shields.io/badge/Rust-lang-000000.svg?style=flat&logo=rust) 4 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/milkshiift/YouTubeTLDR/blob/master/LICENSE) 5 | 6 |
7 |

⚡ A lightweight, self-hosted YouTube video summarizer with Gemini AI
8 | Demo: https://tldr.milkshift.dedyn.io/ 9 |

10 | New summary page screenshot 11 | Summary screenshot 12 |
13 | 14 | ## ✨ Features 15 | 16 | * 🎯 **Customizable Prompts:** Tailor the AI's instructions to get summaries in the format you prefer 17 | * ⚙️ **Model Selection:** Choose any available Gemini model 18 | * 📝 **View Transcript:** Access the full, raw video transcript 19 | * 📚 **History:** Your summaries are saved locally in your browser for future reference 20 | * 🔒 **Privacy-Focused:** Simple Rust server that runs on your own machine. Your data stays yours 21 | * 🎨 **Modern UI:** Clean and beautiful user interface 22 | 23 | ## 🏗️ Philosophy: Minimal by Design 24 | 25 | YouTubeTLDR embraces simplicity — maximum functionality with minimal overhead. 26 | 27 | * 🪶 **Featherweight & Zero Bloat:** Single binary ~**0.3MB**. No databases, no Tokio, no frameworks 28 | * ⚡ **Lightning Fast:** Pure Rust + vanilla HTML/JS 29 | * 🔑 **BYOK:** Bring Your Own Key. Uses your Google Gemini API directly — no proxies, no data collection 30 | * 🎯 **Single Purpose:** Just generates and saves summaries, that's it 31 | 32 | Note: This server is optimized for personal use and utilizes a multithreaded worker pool for concurrency. It is not designed to support hundreds of concurrent users. 33 | 34 | ## 🚀 Getting Started 35 | 36 | ### Prerequisites 37 | 38 | * A [Google Gemini API Key](https://aistudio.google.com/app/apikey) (Free tier with generous limits) 39 | 40 | ### Running the Application 41 | 42 | 1. Download the [latest release](https://github.com/Milkshiift/YouTubeTLDR/releases/latest) and run the executable from console: 43 | ```bash 44 | ./YouTubeTLDR 45 | ``` 46 | 2. Open `http://localhost:8000` in your browser 47 | 3. Click "Advanced Settings" and enter your API key 48 | 4. Paste a YouTube URL and click "Summarize" 49 | 50 | You can change the IP and port with `TLDR_IP` and `TLDR_PORT` environment variables. 51 | The amount of workers can be changed with `TLDR_WORKERS`, set it to the amount of concurrent users you expect. 52 | 53 | ## 🔨 Building from Source 54 | 55 | 1. Install the **nightly** [Rust toolchain](https://www.rust-lang.org/tools/install) 56 | 2. Clone the repository: 57 | ```bash 58 | git clone https://github.com/Milkshiift/YouTubeTLDR.git 59 | cd YouTubeTLDR 60 | ``` 61 | 3. Build the release binary: 62 | ```bash 63 | cargo build --release 64 | ``` 65 | 4. Find your executable at `target/release/YouTubeTLDR` 66 | 67 | By default, the native TLS implementation (like openssl) is used. If you want to use rustls build with `--no-default-features --features rustls-tls` -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "crane": { 4 | "locked": { 5 | "lastModified": 1762538466, 6 | "narHash": "sha256-8zrIPl6J+wLm9MH5ksHcW7BUHo7jSNOu0/hA0ohOOaM=", 7 | "owner": "ipetkov", 8 | "repo": "crane", 9 | "rev": "0cea393fffb39575c46b7a0318386467272182fe", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "ipetkov", 14 | "repo": "crane", 15 | "type": "github" 16 | } 17 | }, 18 | "fenix": { 19 | "inputs": { 20 | "nixpkgs": [ 21 | "nixpkgs" 22 | ], 23 | "rust-analyzer-src": "rust-analyzer-src" 24 | }, 25 | "locked": { 26 | "lastModified": 1763275509, 27 | "narHash": "sha256-DBlu2+xPvGBaNn4RbNaw7r62lzBrf/tOKLgMYlEYhvg=", 28 | "owner": "nix-community", 29 | "repo": "fenix", 30 | "rev": "947fdabcc3a51cec1e38641a11d4cb655fe252e7", 31 | "type": "github" 32 | }, 33 | "original": { 34 | "owner": "nix-community", 35 | "repo": "fenix", 36 | "type": "github" 37 | } 38 | }, 39 | "flake-utils": { 40 | "inputs": { 41 | "systems": "systems" 42 | }, 43 | "locked": { 44 | "lastModified": 1731533236, 45 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 46 | "owner": "numtide", 47 | "repo": "flake-utils", 48 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 49 | "type": "github" 50 | }, 51 | "original": { 52 | "owner": "numtide", 53 | "repo": "flake-utils", 54 | "type": "github" 55 | } 56 | }, 57 | "nixpkgs": { 58 | "locked": { 59 | "lastModified": 1752950548, 60 | "narHash": "sha256-NS6BLD0lxOrnCiEOcvQCDVPXafX1/ek1dfJHX1nUIzc=", 61 | "owner": "NixOS", 62 | "repo": "nixpkgs", 63 | "rev": "c87b95e25065c028d31a94f06a62927d18763fdf", 64 | "type": "github" 65 | }, 66 | "original": { 67 | "owner": "NixOS", 68 | "ref": "nixos-unstable", 69 | "repo": "nixpkgs", 70 | "type": "github" 71 | } 72 | }, 73 | "root": { 74 | "inputs": { 75 | "crane": "crane", 76 | "fenix": "fenix", 77 | "flake-utils": "flake-utils", 78 | "nixpkgs": "nixpkgs" 79 | } 80 | }, 81 | "rust-analyzer-src": { 82 | "flake": false, 83 | "locked": { 84 | "lastModified": 1762860488, 85 | "narHash": "sha256-rMfWMCOo/pPefM2We0iMBLi2kLBAnYoB9thi4qS7uk4=", 86 | "owner": "rust-lang", 87 | "repo": "rust-analyzer", 88 | "rev": "2efc80078029894eec0699f62ec8d5c1a56af763", 89 | "type": "github" 90 | }, 91 | "original": { 92 | "owner": "rust-lang", 93 | "ref": "nightly", 94 | "repo": "rust-analyzer", 95 | "type": "github" 96 | } 97 | }, 98 | "systems": { 99 | "locked": { 100 | "lastModified": 1681028828, 101 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 102 | "owner": "nix-systems", 103 | "repo": "default", 104 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 105 | "type": "github" 106 | }, 107 | "original": { 108 | "owner": "nix-systems", 109 | "repo": "default", 110 | "type": "github" 111 | } 112 | } 113 | }, 114 | "root": "root", 115 | "version": 7 116 | } 117 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release and Publish 2 | 3 | on: 4 | workflow_dispatch: {} 5 | 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.ref }} 8 | cancel-in-progress: true 9 | 10 | env: 11 | BINARY_NAME: YouTubeTLDR 12 | 13 | jobs: 14 | build: 15 | name: Build on ${{ matrix.os }} for ${{ matrix.target || 'default' }} 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | matrix: 19 | os: [ ubuntu-latest, macos-15, windows-latest, ubuntu-24.04-arm, windows-11-arm ] 20 | 21 | steps: 22 | - name: Checkout sources 23 | uses: actions/checkout@v5 24 | 25 | - name: Install Rust toolchain 26 | uses: actions-rust-lang/setup-rust-toolchain@v1 27 | with: 28 | toolchain: nightly 29 | override: true 30 | cache: true 31 | components: rust-src 32 | 33 | - name: Get Target Triple 34 | id: get_target 35 | run: | 36 | if [[ -n "${{ matrix.target }}" ]]; then 37 | TARGET_TRIPLE="${{ matrix.target }}" 38 | else 39 | TARGET_TRIPLE=$(rustc -vV | sed -n 's/host: //p') 40 | fi 41 | echo "TARGET_TRIPLE=${TARGET_TRIPLE}" >> $GITHUB_ENV 42 | shell: bash 43 | 44 | - name: Create Platform Identifier 45 | id: platform 46 | shell: bash 47 | run: | 48 | ARCH=$(echo "${{ env.TARGET_TRIPLE }}" | cut -d'-' -f1) 49 | 50 | OS_NAME=$(echo "${{ runner.os }}" | tr '[:upper:]' '[:lower:]') 51 | 52 | PLATFORM_ID="${ARCH}-${OS_NAME}" 53 | echo "PLATFORM_ID=${PLATFORM_ID}" >> $GITHUB_ENV 54 | 55 | echo "Binary will be named for platform: ${PLATFORM_ID}" 56 | 57 | - name: Build 58 | run: cargo build -Z build-std=std,panic_abort -Z build-std-features= --release --target ${{ env.TARGET_TRIPLE }} 59 | 60 | - name: Prepare Release Assets 61 | shell: bash 62 | run: | 63 | mkdir release_assets 64 | 65 | EXE_SUFFIX="" 66 | if [[ "${{ runner.os }}" == "Windows" ]]; then 67 | EXE_SUFFIX=".exe" 68 | fi 69 | 70 | BINARY_FILENAME="${{ env.BINARY_NAME }}-${{ env.PLATFORM_ID }}${EXE_SUFFIX}" 71 | 72 | BIN_SRC_PATH="target/${{ env.TARGET_TRIPLE }}/release/${{ env.BINARY_NAME }}${EXE_SUFFIX}" 73 | BIN_DEST_PATH="release_assets/${BINARY_FILENAME}" 74 | 75 | echo "Copying binary from ${BIN_SRC_PATH} to ${BIN_DEST_PATH}" 76 | cp "${BIN_SRC_PATH}" "${BIN_DEST_PATH}" 77 | 78 | echo "Listing prepared release assets:" 79 | ls -R release_assets 80 | 81 | - name: Upload artifact 82 | uses: actions/upload-artifact@v5 83 | with: 84 | name: app-binaries-${{ env.PLATFORM_ID }} 85 | path: release_assets/ 86 | 87 | release: 88 | name: Create GitHub Release 89 | runs-on: ubuntu-latest 90 | needs: build 91 | permissions: 92 | contents: write 93 | 94 | steps: 95 | - name: Download all build artifacts 96 | uses: actions/download-artifact@v5 97 | with: 98 | path: release-assets/ 99 | 100 | - name: List downloaded files 101 | run: ls -R release-assets/ 102 | 103 | - name: Create Release and Upload Assets 104 | uses: softprops/action-gh-release@v2 105 | with: 106 | tag_name: ${{github.ref_name}} 107 | name: ${{ startsWith(github.ref, 'refs/tags/') && format('Release {0}', github.ref_name) || format('Build from {0}', github.ref_name) }} 108 | draft: true 109 | generate_release_notes: true 110 | files: release-assets/**/* -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "YouTubeTLDR server"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | crane.url = "github:ipetkov/crane"; 8 | crane.inputs.nixpkgs.follows = "nixpkgs"; 9 | fenix = { 10 | url = "github:nix-community/fenix"; 11 | inputs.nixpkgs.follows = "nixpkgs"; 12 | }; 13 | }; 14 | 15 | outputs = { self, nixpkgs, flake-utils, fenix, crane, ... }: 16 | flake-utils.lib.eachDefaultSystem (system: 17 | let 18 | pkgs = import nixpkgs { 19 | inherit system; 20 | }; 21 | 22 | rust-toolchain = fenix.packages.${system}.default.toolchain; 23 | craneLib = (crane.mkLib pkgs).overrideToolchain rust-toolchain; 24 | 25 | project = pkgs.lib.importTOML ./Cargo.toml; 26 | 27 | youtubetldr = craneLib.buildPackage { 28 | pname = project.package.name; 29 | version = project.package.version; 30 | src = ./.; 31 | nativeBuildInputs = with pkgs; [ 32 | mold 33 | ]; 34 | cargoExtraArgs = "--no-default-features --features rustls-tls"; 35 | }; 36 | in 37 | { 38 | packages.default = youtubetldr; 39 | 40 | devShells = { 41 | default = pkgs.mkShell { 42 | buildInputs = with pkgs; [ 43 | openssl 44 | pkg-config 45 | ]; 46 | }; 47 | }; 48 | }) // { 49 | nixosModules.default = { config, pkgs, ... }: 50 | let 51 | cfg = config.services.youtubetldr; 52 | in 53 | { 54 | options.services.youtubetldr = { 55 | enable = pkgs.lib.mkEnableOption "Enable the YouTubeTLDR server"; 56 | 57 | ip = pkgs.lib.mkOption { 58 | type = pkgs.lib.types.str; 59 | default = "0.0.0.0"; 60 | description = "IP address to bind to"; 61 | }; 62 | 63 | port = pkgs.lib.mkOption { 64 | type = pkgs.lib.types.port; 65 | default = 8000; 66 | description = "Port to listen on"; 67 | }; 68 | 69 | workers = pkgs.lib.mkOption { 70 | type = pkgs.lib.types.int; 71 | default = 4; 72 | description = "Number of worker threads"; 73 | }; 74 | }; 75 | 76 | config = pkgs.lib.mkIf cfg.enable { 77 | systemd.services.youtubetldr = { 78 | description = "YouTubeTLDR server"; 79 | after = [ "network.target" ]; 80 | wantedBy = [ "multi-user.target" ]; 81 | 82 | serviceConfig = { 83 | ExecStart = "${self.packages.${pkgs.system}.default}/bin/YouTubeTLDR"; 84 | Restart = "on-failure"; 85 | User = "youtubetldr"; 86 | Group = "youtubetldr"; 87 | CapabilityBoundingSet = ""; # No capabilities 88 | PrivateTmp = true; # Private /tmp and /var/tmp 89 | NoNewPrivileges = true; # Prevent privilege escalation 90 | ProtectSystem = "strict"; # Protect the /boot, /etc, /usr, and /opt hierarchies 91 | ProtectHome = true; # Protect /home, /root, /run/user 92 | RestrictSUIDSGID = true; # Prevent SUID/SGID bits from being set 93 | RestrictRealtime = true; # Prevent realtime scheduling 94 | RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; # Only allow IPv4 and IPv6 sockets 95 | SystemCallFilter = [ "~@cpu-emulation @debug @keyring @mount @module @obsolete @raw-io @reboot @swap" ]; # Restrict syscalls 96 | Environment = [ 97 | "TLDR_IP=${cfg.ip}" 98 | "TLDR_PORT=${toString cfg.port}" 99 | "TLDR_WORKERS=${toString cfg.workers}" 100 | ]; 101 | }; 102 | }; 103 | 104 | users.users.youtubetldr = { 105 | isSystemUser = true; 106 | group = "youtubetldr"; 107 | }; 108 | 109 | users.groups.youtubetldr = {}; 110 | }; 111 | }; 112 | }; 113 | } -------------------------------------------------------------------------------- /src/gemini.rs: -------------------------------------------------------------------------------- 1 | use miniserde::{Deserialize, Serialize, json}; 2 | use std::fmt; 3 | 4 | #[derive(Debug)] 5 | pub enum Error { 6 | Request(minreq::Error), 7 | Api { status: u16, body: String }, 8 | Json(miniserde::Error), 9 | NoTextInResponse, 10 | } 11 | 12 | impl fmt::Display for Error { 13 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 14 | match self { 15 | Self::Request(_) => write!(f, "Failed to send request to the Gemini API"), 16 | Self::Api { status, body } => { 17 | write!(f, "Gemini API returned an error (status {status}): {body}") 18 | } 19 | Self::Json(_) => write!(f, "Failed to parse a response from the Gemini API"), 20 | Self::NoTextInResponse => write!(f, "The API response did not contain any text"), 21 | } 22 | } 23 | } 24 | 25 | impl std::error::Error for Error { 26 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 27 | match self { 28 | Self::Request(e) => Some(e), 29 | Self::Json(e) => Some(e), 30 | Self::Api { .. } | Self::NoTextInResponse => None, 31 | } 32 | } 33 | } 34 | 35 | const BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta/models"; 36 | 37 | #[derive(Deserialize)] 38 | struct GeminiResponse { 39 | candidates: Vec, 40 | } 41 | 42 | #[derive(Deserialize)] 43 | struct Candidate { 44 | content: ContentResponse, 45 | } 46 | 47 | #[derive(Deserialize)] 48 | struct ContentResponse { 49 | parts: Vec, 50 | } 51 | 52 | #[derive(Deserialize)] 53 | struct PartResponse { 54 | text: String, 55 | } 56 | 57 | #[derive(Serialize)] 58 | struct GeminiRequest<'a> { 59 | system_instruction: SystemInstruction<'a>, 60 | contents: Vec>, 61 | #[serde(rename = "generationConfig")] 62 | generation_config: GenerationConfig<'a>, 63 | #[serde(rename = "safetySettings")] 64 | safety_settings: Vec>, 65 | } 66 | 67 | #[derive(Serialize)] 68 | struct SystemInstruction<'a> { 69 | parts: Vec>, 70 | } 71 | 72 | #[derive(Serialize)] 73 | struct ContentRequest<'a> { 74 | role: &'a str, 75 | parts: Vec>, 76 | } 77 | 78 | #[derive(Serialize)] 79 | struct PartRequest<'a> { 80 | text: &'a str, 81 | } 82 | 83 | #[derive(Serialize)] 84 | struct GenerationConfig<'a> { 85 | temperature: f32, 86 | #[serde(rename = "topK")] 87 | top_k: u32, 88 | #[serde(rename = "topP")] 89 | top_p: f32, 90 | #[serde(rename = "maxOutputTokens")] 91 | max_output_tokens: u32, 92 | #[serde(rename = "stopSequences")] 93 | stop_sequences: Vec<&'a str>, 94 | } 95 | 96 | #[derive(Serialize)] 97 | struct SafetySetting<'a> { 98 | category: &'a str, 99 | threshold: &'a str, 100 | } 101 | 102 | pub fn summarize( 103 | api_key: &str, 104 | model: &str, 105 | system_prompt: &str, 106 | transcript: &str, 107 | ) -> Result { 108 | let req_url = format!("{BASE_URL}/{model}:generateContent?key={api_key}"); 109 | 110 | let request_body = GeminiRequest { 111 | system_instruction: SystemInstruction { 112 | parts: vec![PartRequest { 113 | text: system_prompt, 114 | }], 115 | }, 116 | contents: vec![ContentRequest { 117 | role: "user", 118 | parts: vec![PartRequest { text: transcript }], 119 | }], 120 | generation_config: GenerationConfig { 121 | temperature: 1.0, 122 | top_k: 64, 123 | top_p: 0.95, 124 | max_output_tokens: 65536, 125 | stop_sequences: vec![], 126 | }, 127 | safety_settings: vec![ 128 | SafetySetting { 129 | category: "HARM_CATEGORY_HARASSMENT", 130 | threshold: "BLOCK_NONE", 131 | }, 132 | SafetySetting { 133 | category: "HARM_CATEGORY_HATE_SPEECH", 134 | threshold: "BLOCK_NONE", 135 | }, 136 | SafetySetting { 137 | category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", 138 | threshold: "BLOCK_NONE", 139 | }, 140 | SafetySetting { 141 | category: "HARM_CATEGORY_DANGEROUS_CONTENT", 142 | threshold: "BLOCK_NONE", 143 | }, 144 | ], 145 | }; 146 | 147 | let body_str = json::to_vec(&request_body); 148 | 149 | let response = minreq::post(req_url) 150 | .with_timeout(120) 151 | .with_body(body_str) 152 | .send() 153 | .map_err(Error::Request)?; 154 | 155 | if !(200..=299).contains(&response.status_code) { 156 | let body = response.as_str().unwrap_or("No response body").to_string(); 157 | return Err(Error::Api { 158 | status: response.status_code as u16, 159 | body, 160 | }); 161 | } 162 | 163 | let reply: GeminiResponse = json::from_slice(response.as_bytes()).map_err(Error::Json)?; 164 | 165 | reply 166 | .candidates 167 | .first() 168 | .and_then(|c| c.content.parts.first()) 169 | .map(|p| p.text.clone()) 170 | .ok_or(Error::NoTextInResponse) 171 | } 172 | -------------------------------------------------------------------------------- /src/subtitle.rs: -------------------------------------------------------------------------------- 1 | use miniserde::{Deserialize, json}; 2 | use std::error::Error; 3 | 4 | #[derive(Deserialize)] 5 | struct PlayerDataResponse { 6 | captions: Option, 7 | #[serde(rename = "videoDetails")] 8 | video_details: Option, 9 | } 10 | 11 | #[derive(Deserialize)] 12 | struct VideoDetails { 13 | title: String, 14 | } 15 | 16 | #[derive(Deserialize)] 17 | struct Captions { 18 | #[serde(rename = "playerCaptionsTracklistRenderer")] 19 | player_captions_tracklist_renderer: Option, 20 | } 21 | 22 | #[derive(Deserialize)] 23 | struct PlayerCaptionsTracklistRenderer { 24 | #[serde(rename = "captionTracks")] 25 | caption_tracks: Vec, 26 | } 27 | 28 | #[derive(Deserialize)] 29 | struct CaptionTrack { 30 | #[serde(rename = "baseUrl")] 31 | base_url: String, 32 | #[serde(rename = "languageCode")] 33 | language_code: String, 34 | } 35 | 36 | #[derive(Deserialize)] 37 | struct JsonCaptionResponse { 38 | events: Vec, 39 | } 40 | 41 | #[derive(Deserialize)] 42 | struct JsonCaptionEvent { 43 | segs: Option>, 44 | } 45 | 46 | #[derive(Deserialize)] 47 | struct CaptionSegment { 48 | utf8: String, 49 | } 50 | 51 | const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"; 52 | const API_KEY: &str = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"; 53 | 54 | pub fn get_video_data(video_url: &str, language: &str) -> Result<(String, String), Box> { 55 | let video_id = 56 | extract_video_id(video_url).ok_or_else(|| format!("Invalid YouTube URL: {video_url}"))?; 57 | 58 | get_transcript_and_title(video_id, language) 59 | } 60 | 61 | fn get_transcript_and_title( 62 | video_id: &str, 63 | language: &str, 64 | ) -> Result<(String, String), Box> { 65 | let request_body = format!( 66 | r#"{{ 67 | "context": {{ 68 | "client": {{ 69 | "clientName": "WEB", 70 | "clientVersion": "2.20251113.00.00" 71 | }} 72 | }}, 73 | "videoId": "{video_id}" 74 | }}"# 75 | ); 76 | 77 | let player_response = minreq::post(format!("https://www.youtube.com/youtubei/v1/player?prettyPrint=false&key={API_KEY}")) 78 | .with_header("User-Agent", USER_AGENT) 79 | .with_header("Referer", "https://www.youtube.com/") 80 | .with_body(request_body) 81 | .send()?; 82 | 83 | let player_data: PlayerDataResponse = json::from_slice(player_response.as_bytes())?; 84 | 85 | let video_title = player_data 86 | .video_details 87 | .ok_or("Video not found or server IP blocked by YouTube")? 88 | .title; 89 | 90 | let tracks = player_data 91 | .captions 92 | .and_then(|c| c.player_captions_tracklist_renderer) 93 | .map(|r| r.caption_tracks) 94 | .ok_or_else(|| format!("No captions found for video: {video_id}"))?; 95 | 96 | let track = select_best_track(&tracks, language)?; 97 | 98 | let url = format!("{}&fmt=json3", track.base_url.replace("\\u0026", "&")); 99 | let caption_response: JsonCaptionResponse = 100 | json::from_slice(minreq::get(url).send()?.as_bytes())?; 101 | let transcript = process_json_captions(caption_response.events); 102 | 103 | Ok((transcript, video_title)) 104 | } 105 | 106 | fn extract_video_id(url: &str) -> Option<&str> { 107 | const PATTERNS: &[&str] = &["v=", "/embed/", "/live/", "/v/", "/shorts/", "youtu.be/"]; 108 | 109 | for pattern in PATTERNS { 110 | if let Some(pos) = url.find(pattern) { 111 | let start = pos + pattern.len(); 112 | return url.get(start..start + 11); 113 | } 114 | } 115 | None 116 | } 117 | 118 | fn select_best_track<'a>( 119 | tracks: &'a [CaptionTrack], 120 | language: &str, 121 | ) -> Result<&'a CaptionTrack, Box> { 122 | // manual > punctuated ASR > plain ASR 123 | let mut best = None; 124 | let mut priority = 999; 125 | 126 | for track in tracks { 127 | if track.language_code == language { 128 | let track_priority = if !track.base_url.contains("kind=asr") { 129 | 0 // Manual 130 | } else if track.base_url.contains("variant=punctuated") { 131 | 1 // Punctuated ASR 132 | } else { 133 | 2 // Plain ASR 134 | }; 135 | 136 | if track_priority < priority { 137 | best = Some(track); 138 | priority = track_priority; 139 | if priority == 0 { 140 | break; 141 | } // Found manual, stop searching 142 | } 143 | } 144 | } 145 | 146 | best.ok_or_else(|| { 147 | let available: Vec<_> = tracks.iter().map(|t| &t.language_code).collect(); 148 | format!("No captions for '{language}'. Available: {available:?}").into() 149 | }) 150 | } 151 | 152 | fn process_json_captions(events: Vec) -> String { 153 | let mut result = String::with_capacity(events.len() * 50); 154 | 155 | for event in events { 156 | if let Some(segs) = event.segs { 157 | for seg in segs { 158 | let text = seg.utf8.trim(); 159 | if !text.is_empty() { 160 | if !result.is_empty() { 161 | result.push(' '); 162 | } 163 | result.push_str(text); 164 | } 165 | } 166 | } 167 | } 168 | 169 | result 170 | } 171 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | YouTube TLDR 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 19 | 38 | 39 |
40 |
41 |
42 |

YouTube TLDR

43 |

Enter a YouTube URL to get an AI-generated summary.

44 |
45 |
46 | 47 | 48 |
49 |
50 | Settings 51 |
52 |
53 | 56 | 57 |
58 | 59 |
60 | 63 | 64 |
65 | 66 |
67 | 70 | 71 |
72 | 73 |
74 | 77 | 78 |
79 | 80 |
81 | 82 | 85 |
86 | 87 |
88 | 89 | 92 |
93 |
94 |
95 |
96 | 97 | 131 |
132 | 133 |
134 | 135 | 136 | 137 | 140 | 141 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod gemini; 2 | mod subtitle; 3 | 4 | use crate::subtitle::get_video_data; 5 | use flume::{Receiver, bounded}; 6 | use miniserde::{Deserialize, Serialize, json}; 7 | use std::env; 8 | use std::io::{self, BufRead, BufReader, Read, Write}; 9 | use std::net::{SocketAddr, TcpListener, TcpStream}; 10 | use std::sync::Arc; 11 | use std::thread; 12 | use std::time::Duration; 13 | 14 | #[derive(Deserialize)] 15 | struct SummarizeRequest { 16 | url: String, 17 | api_key: Option, 18 | model: Option, 19 | system_prompt: Option, 20 | language: Option, 21 | dry_run: bool, 22 | transcript_only: bool, 23 | } 24 | 25 | #[derive(Serialize)] 26 | struct SummarizeResponse { 27 | summary: String, 28 | subtitles: String, 29 | video_name: String, 30 | } 31 | 32 | struct WorkItem { 33 | stream: TcpStream, 34 | addr: SocketAddr, 35 | } 36 | 37 | struct ServerConfig { 38 | addr: String, 39 | num_workers: usize, 40 | read_timeout: Duration, 41 | write_timeout: Duration, 42 | max_body_size: usize, 43 | } 44 | 45 | impl ServerConfig { 46 | fn from_env() -> Self { 47 | let ip = env::var("TLDR_IP").unwrap_or_else(|_| "0.0.0.0".into()); 48 | let port = env::var("TLDR_PORT").unwrap_or_else(|_| "8000".into()); 49 | 50 | Self { 51 | addr: format!("{ip}:{port}"), 52 | num_workers: env::var("TLDR_WORKERS") 53 | .ok() 54 | .and_then(|s| s.parse().ok()) 55 | .unwrap_or(4), 56 | read_timeout: Duration::from_secs(15), 57 | write_timeout: Duration::from_secs(15), 58 | max_body_size: 10 * 1024 * 1024, 59 | } 60 | } 61 | } 62 | 63 | struct StaticResource { 64 | content: &'static [u8], 65 | content_type: &'static str, 66 | } 67 | 68 | macro_rules! static_resource { 69 | ($name:ident, $path:expr, $content_type:expr) => { 70 | static $name: StaticResource = StaticResource { 71 | content: include_bytes!(concat!("../static/", $path, ".gz")), 72 | content_type: $content_type, 73 | }; 74 | }; 75 | } 76 | 77 | static_resource!(HTML_RESOURCE, "index.html", "text/html; charset=utf-8"); 78 | static_resource!(CSS_RESOURCE, "style.css", "text/css; charset=utf-8"); 79 | static_resource!( 80 | JS_RESOURCE, 81 | "script.js", 82 | "application/javascript; charset=utf-8" 83 | ); 84 | 85 | fn main() -> io::Result<()> { 86 | let config = Arc::new(ServerConfig::from_env()); 87 | 88 | let listener = TcpListener::bind(&config.addr)?; 89 | listener.set_nonblocking(false)?; // Better performance 90 | 91 | println!("✅ Server started at http://{}", config.addr); 92 | println!("✅ Spawning {} worker threads", config.num_workers); 93 | 94 | let (sender, receiver) = bounded(100); 95 | 96 | for id in 0..config.num_workers { 97 | let receiver = receiver.clone(); 98 | let config = Arc::clone(&config); 99 | thread::spawn(move || worker(id, &receiver, &config)); 100 | } 101 | 102 | println!("▶️ Ready to accept requests"); 103 | 104 | for stream in listener.incoming() { 105 | match stream { 106 | Ok(stream) => { 107 | let addr = match stream.peer_addr() { 108 | Ok(addr) => addr, 109 | Err(e) => { 110 | eprintln!("❌ Failed to get peer address: {e}"); 111 | continue; 112 | } 113 | }; 114 | 115 | let _ = stream.set_nodelay(true); 116 | let _ = stream.set_read_timeout(Some(config.read_timeout)); 117 | let _ = stream.set_write_timeout(Some(config.write_timeout)); 118 | 119 | let work_item = WorkItem { stream, addr }; 120 | 121 | if sender.try_send(work_item).is_err() { 122 | eprintln!("⚠️ Queue full, rejecting connection from {addr}"); 123 | } 124 | } 125 | Err(e) => { 126 | eprintln!("❌ Accept failed: {e}"); 127 | } 128 | } 129 | } 130 | Ok(()) 131 | } 132 | 133 | fn worker(id: usize, receiver: &Receiver, config: &Arc) { 134 | println!(" Worker {id} started"); 135 | 136 | let mut buffer = Vec::with_capacity(4096); 137 | 138 | while let Ok(mut work_item) = receiver.recv() { 139 | buffer.clear(); 140 | 141 | if let Err(e) = handle_request(&mut work_item.stream, config, &mut buffer) { 142 | eprintln!("❌ Worker {} error handling {}: {}", id, work_item.addr, e); 143 | let _ = write_error_response( 144 | &mut work_item.stream, 145 | "500 Internal Server Error", 146 | &e.to_string(), 147 | ); 148 | } 149 | } 150 | 151 | println!(" Worker {id} shutting down"); 152 | } 153 | 154 | fn handle_request( 155 | stream: &mut TcpStream, 156 | config: &ServerConfig, 157 | buffer: &mut Vec, 158 | ) -> io::Result<()> { 159 | let mut reader = BufReader::with_capacity(8192, stream.try_clone()?); 160 | 161 | let mut request_line = String::new(); 162 | reader.read_line(&mut request_line)?; 163 | 164 | if request_line.is_empty() { 165 | return Err(io::Error::new(io::ErrorKind::InvalidData, "Empty request")); 166 | } 167 | 168 | let parts: Vec<&str> = request_line.split_whitespace().collect(); 169 | if parts.len() < 2 { 170 | return Err(io::Error::new( 171 | io::ErrorKind::InvalidData, 172 | "Invalid request line", 173 | )); 174 | } 175 | 176 | let method = parts[0]; 177 | let path = parts[1]; 178 | 179 | match method { 180 | "GET" => handle_get(path, stream), 181 | "POST" if path == "/api/summarize" => { 182 | // Read headers 183 | let mut headers = Vec::new(); 184 | let mut content_length = None; 185 | 186 | loop { 187 | let mut line = String::new(); 188 | let bytes_read = reader.read_line(&mut line)?; 189 | 190 | if bytes_read == 0 { 191 | return Err(io::Error::new( 192 | io::ErrorKind::UnexpectedEof, 193 | "Unexpected EOF", 194 | )); 195 | } 196 | 197 | if line == "\r\n" || line == "\n" { 198 | break; 199 | } 200 | 201 | if line.to_lowercase().starts_with("content-length:") 202 | && let Some(value) = line.split(':').nth(1) { 203 | content_length = value.trim().parse().ok(); 204 | } 205 | 206 | headers.push(line); 207 | 208 | if headers.len() > 100 { 209 | return Err(io::Error::new( 210 | io::ErrorKind::InvalidData, 211 | "Too many headers", 212 | )); 213 | } 214 | } 215 | 216 | let content_length = content_length.ok_or_else(|| { 217 | io::Error::new(io::ErrorKind::InvalidInput, "Missing Content-Length") 218 | })?; 219 | 220 | if content_length > config.max_body_size { 221 | return Err(io::Error::new( 222 | io::ErrorKind::InvalidData, 223 | "Request body too large", 224 | )); 225 | } 226 | 227 | // Read body 228 | buffer.clear(); 229 | buffer.resize(content_length, 0); 230 | reader.read_exact(buffer)?; 231 | 232 | // Process 233 | let req: SummarizeRequest = json::from_slice(buffer).map_err(|e| { 234 | io::Error::new(io::ErrorKind::InvalidData, format!("Invalid JSON: {e}")) 235 | })?; 236 | 237 | let response_payload = perform_summary_work(&req) 238 | .map_err(|e| io::Error::other(format!("Processing error: {e}")))?; 239 | 240 | let response_body = json::to_vec(&response_payload); 241 | 242 | write_response(stream, "200 OK", "application/json", &response_body) 243 | } 244 | _ => write_error_response(stream, "405 Method Not Allowed", "Method Not Allowed"), 245 | } 246 | } 247 | 248 | fn handle_get(path: &str, stream: &mut TcpStream) -> io::Result<()> { 249 | let resource = match path { 250 | "/" | "/index.html" => Some(&HTML_RESOURCE), 251 | "/style.css" => Some(&CSS_RESOURCE), 252 | "/script.js" => Some(&JS_RESOURCE), 253 | _ => None, 254 | }; 255 | 256 | match resource { 257 | Some(res) => write_static_response(stream, res), 258 | None => write_error_response(stream, "404 Not Found", "Not Found"), 259 | } 260 | } 261 | 262 | fn write_static_response(stream: &mut TcpStream, resource: &StaticResource) -> io::Result<()> { 263 | let response = format!( 264 | "HTTP/1.1 200 OK\r\n\ 265 | Content-Type: {}\r\n\ 266 | Content-Encoding: gzip\r\n\ 267 | Content-Length: {}\r\n\ 268 | Cache-Control: public, max-age=3600\r\n\ 269 | Connection: close\r\n\r\n", 270 | resource.content_type, 271 | resource.content.len() 272 | ); 273 | 274 | stream.write_all(response.as_bytes())?; 275 | stream.write_all(resource.content)?; 276 | stream.flush() 277 | } 278 | 279 | fn write_response( 280 | stream: &mut TcpStream, 281 | status: &str, 282 | content_type: &str, 283 | content: &[u8], 284 | ) -> io::Result<()> { 285 | let response = format!( 286 | "HTTP/1.1 {}\r\n\ 287 | Content-Type: {}\r\n\ 288 | Content-Length: {}\r\n\ 289 | Connection: close\r\n\r\n", 290 | status, 291 | content_type, 292 | content.len() 293 | ); 294 | 295 | stream.write_all(response.as_bytes())?; 296 | stream.write_all(content)?; 297 | stream.flush() 298 | } 299 | 300 | fn write_error_response(stream: &mut TcpStream, status: &str, msg: &str) -> io::Result<()> { 301 | write_response(stream, status, "text/plain; charset=utf-8", msg.as_bytes()) 302 | } 303 | 304 | fn perform_summary_work(req: &SummarizeRequest) -> Result { 305 | if req.dry_run { 306 | let test_md = include_str!("./markdown_test.md"); 307 | return Ok(SummarizeResponse { 308 | summary: test_md.to_string(), 309 | subtitles: test_md.to_string(), 310 | video_name: "Dry Run".to_string(), 311 | }); 312 | } 313 | 314 | let language = req.language.as_deref().unwrap_or("en"); 315 | let (transcript, video_name) = 316 | get_video_data(&req.url, language).map_err(|e| format!("Transcript error: {e}"))?; 317 | 318 | if req.transcript_only { 319 | return Ok(SummarizeResponse { 320 | summary: transcript.clone(), 321 | subtitles: transcript, 322 | video_name, 323 | }); 324 | } 325 | 326 | let api_key = 327 | req.api_key.as_deref().filter(|k| !k.is_empty()).ok_or( 328 | "Missing Gemini API key. Get one here: https://aistudio.google.com/app/apikey", 329 | )?; 330 | 331 | let model = req 332 | .model 333 | .as_deref() 334 | .filter(|m| !m.is_empty()) 335 | .ok_or("Missing model name")?; 336 | 337 | let system_prompt = req 338 | .system_prompt 339 | .as_deref() 340 | .filter(|p| !p.is_empty()) 341 | .ok_or("Missing system prompt")?; 342 | 343 | let summary = gemini::summarize(api_key, model, system_prompt, &transcript) 344 | .map_err(|e| format!("API error: {e}"))?; 345 | 346 | Ok(SummarizeResponse { 347 | summary, 348 | subtitles: transcript, 349 | video_name, 350 | }) 351 | } 352 | -------------------------------------------------------------------------------- /static/script.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', () => { 2 | const config = { 3 | baseURL: `${location.protocol}//${location.hostname}${location.port ? ':' + location.port : ''}`, 4 | storageKeys: { 5 | apiKey: 'youtube-tldr-api-key', 6 | model: 'youtube-tldr-model', 7 | language: 'youtube-tldr-language', 8 | systemPrompt: 'youtube-tldr-system-prompt', 9 | dryRun: 'youtube-tldr-dry-run', 10 | transcriptOnly: 'youtube-tldr-transcript-only', 11 | summaries: 'youtube-tldr-summaries' 12 | }, 13 | defaults: { 14 | model: 'gemini-2.5-flash', 15 | systemPrompt: "You are an expert video summarizer specializing in creating structured, accurate overviews. Given a YouTube video transcript, extract and present the most crucial information in an article-style format. Prioritize fidelity to the original content, ensuring all significant points, arguments, and key details are faithfully represented. Organize the summary logically with clear, descriptive headings and/or concise bullet points. For maximum skim-readability, bold key terms, core concepts, and critical takeaways within the text. Eliminate advertisements, sponsorships, conversational filler, repeated phrases, and irrelevant tangents, but retain all essential content.", 16 | language: 'en' 17 | } 18 | }; 19 | 20 | const dom = { 21 | // Settings 22 | apiKey: document.getElementById('api-key'), 23 | model: document.getElementById('model'), 24 | language: document.getElementById('language'), 25 | systemPrompt: document.getElementById('system-prompt'), 26 | dryRun: document.getElementById('dry-run'), 27 | transcriptOnly: document.getElementById('transcript-only'), 28 | // Sidebar 29 | sidebar: document.getElementById('sidebar'), 30 | newSummaryBtn: document.getElementById('new-summary-btn'), 31 | savedSummariesList: document.getElementById('saved-summaries-list'), 32 | clearSummariesBtn: document.getElementById('clear-summaries-btn'), 33 | menuToggleBtn: document.getElementById('menu-toggle-btn'), 34 | closeSidebarBtn: document.getElementById('close-sidebar-btn'), 35 | sidebarOverlay: document.getElementById('sidebar-overlay'), 36 | // Main View 37 | mainContent: document.getElementById('main-content'), 38 | welcomeView: document.getElementById('welcome-view'), 39 | summaryView: document.getElementById('summary-view'), 40 | form: document.getElementById('summary-form'), 41 | urlInput: document.getElementById('youtube-url'), 42 | // Status & Output 43 | statusContainer: document.getElementById('status-container'), 44 | loader: document.getElementById('loader'), 45 | errorMessage: document.getElementById('error-message'), 46 | summaryContainer: document.getElementById('summary-container'), 47 | summaryTitleText: document.getElementById('summary-title-text'), 48 | summaryOutput: document.getElementById('summary-output'), 49 | transcriptSection: document.getElementById('transcript-section'), 50 | transcriptText: document.getElementById('transcript-text'), 51 | copySummaryBtn: document.getElementById('copy-summary-btn'), 52 | copyTranscriptBtn: document.getElementById('copy-transcript-btn'), 53 | videoLink: document.getElementById('video-link'), 54 | }; 55 | 56 | const state = { 57 | summaries: [], 58 | activeSummaryIndex: -1, 59 | isLoading: false, 60 | error: null, 61 | }; 62 | 63 | const app = { 64 | init() { 65 | this.loadSettings(); 66 | this.loadSummaries(); 67 | this.addEventListeners(); 68 | this.render(); 69 | }, 70 | 71 | addEventListeners() { 72 | dom.form.addEventListener('submit', this.handleFormSubmit.bind(this)); 73 | dom.clearSummariesBtn.addEventListener('click', this.handleClearSummaries.bind(this)); 74 | dom.newSummaryBtn.addEventListener('click', this.handleNewSummary.bind(this)); 75 | dom.savedSummariesList.addEventListener('click', this.handleSidebarClick.bind(this)); 76 | 77 | dom.copySummaryBtn.addEventListener('click', (e) => this.handleCopyClick(e, dom.summaryOutput.mdContent, dom.copySummaryBtn)); 78 | dom.copyTranscriptBtn.addEventListener('click', (e) => this.handleCopyClick(e, dom.transcriptText.textContent, dom.copyTranscriptBtn)); 79 | 80 | [dom.menuToggleBtn, dom.closeSidebarBtn, dom.sidebarOverlay].forEach(el => { 81 | if (el) el.addEventListener('click', () => this.toggleSidebar()); 82 | }); 83 | 84 | [dom.apiKey, dom.model, dom.systemPrompt].forEach(el => el.addEventListener('change', this.saveSettings)); 85 | [dom.dryRun, dom.transcriptOnly].forEach(el => el.addEventListener('change', this.saveSettings)); 86 | }, 87 | 88 | loadSummaries() { 89 | state.summaries = JSON.parse(localStorage.getItem(config.storageKeys.summaries)) || []; 90 | if (state.summaries.length > 0) { 91 | state.activeSummaryIndex = 0; 92 | } 93 | }, 94 | 95 | saveSummaries() { 96 | localStorage.setItem(config.storageKeys.summaries, JSON.stringify(state.summaries)); 97 | this.render(); 98 | }, 99 | 100 | loadSettings() { 101 | dom.apiKey.value = localStorage.getItem(config.storageKeys.apiKey) || ''; 102 | dom.model.value = localStorage.getItem(config.storageKeys.model) || config.defaults.model; 103 | dom.language.value = localStorage.getItem(config.storageKeys.language) || config.defaults.language; 104 | dom.systemPrompt.value = localStorage.getItem(config.storageKeys.systemPrompt) || config.defaults.systemPrompt; 105 | dom.dryRun.checked = localStorage.getItem(config.storageKeys.dryRun) === 'true'; 106 | dom.transcriptOnly.checked = localStorage.getItem(config.storageKeys.transcriptOnly) === 'true'; 107 | }, 108 | 109 | saveSettings() { 110 | localStorage.setItem(config.storageKeys.apiKey, dom.apiKey.value); 111 | localStorage.setItem(config.storageKeys.model, dom.model.value); 112 | localStorage.setItem(config.storageKeys.language, dom.language.value); 113 | localStorage.setItem(config.storageKeys.systemPrompt, dom.systemPrompt.value); 114 | localStorage.setItem(config.storageKeys.dryRun, dom.dryRun.checked); 115 | localStorage.setItem(config.storageKeys.transcriptOnly, dom.transcriptOnly.checked); 116 | }, 117 | 118 | async handleFormSubmit(event) { 119 | event.preventDefault(); 120 | const url = dom.urlInput.value.trim(); 121 | if (!url) { 122 | state.error = "Please enter a YouTube URL."; 123 | this.render(); 124 | return; 125 | } 126 | 127 | this.saveSettings(); 128 | state.isLoading = true; 129 | state.error = null; 130 | state.activeSummaryIndex = -1; 131 | this.render(); 132 | 133 | try { 134 | const response = await fetch(`${config.baseURL}/api/summarize`, { 135 | method: 'POST', 136 | headers: { 'Content-Type': 'application/json' }, 137 | body: JSON.stringify({ 138 | url, 139 | api_key: dom.apiKey.value, 140 | model: dom.model.value, 141 | language: dom.language.value, 142 | system_prompt: dom.systemPrompt.value, 143 | dry_run: dom.dryRun.checked, 144 | transcript_only: dom.transcriptOnly.checked, 145 | }), 146 | }); 147 | 148 | const responseText = await response.text(); 149 | 150 | if (!response.ok) { 151 | let errorMsg = responseText; 152 | try { 153 | const errorData = JSON.parse(responseText); 154 | if (errorData && errorData.error) { 155 | errorMsg = errorData.error; 156 | } 157 | } catch (e) { 158 | // Not JSON, or JSON without .error, use text as is. 159 | } 160 | throw new Error(errorMsg || `Server error: ${response.status}`); 161 | } 162 | 163 | const data = JSON.parse(responseText); 164 | 165 | const newSummary = { 166 | name: data.video_name, 167 | summary: data.summary, 168 | transcript: data.subtitles, 169 | url: url 170 | }; 171 | 172 | state.summaries.unshift(newSummary); 173 | state.activeSummaryIndex = 0; 174 | 175 | } catch (error) { 176 | console.error('Summarization failed:', error); 177 | state.error = error.message; 178 | } finally { 179 | state.isLoading = false; 180 | this.saveSummaries(); 181 | } 182 | }, 183 | 184 | handleNewSummary() { 185 | state.activeSummaryIndex = -1; 186 | state.error = null; 187 | dom.urlInput.value = ''; 188 | this.render(); 189 | if (this.isMobile()) this.toggleSidebar(false); 190 | }, 191 | 192 | handleClearSummaries() { 193 | if (confirm('Are you sure you want to clear all saved summaries?')) { 194 | state.summaries = []; 195 | state.activeSummaryIndex = -1; 196 | state.error = null; 197 | this.saveSummaries(); 198 | } 199 | }, 200 | 201 | handleSidebarClick(e) { 202 | const link = e.target.closest('a[data-index]'); 203 | const deleteBtn = e.target.closest('button[data-index]'); 204 | 205 | if (deleteBtn) { 206 | e.preventDefault(); 207 | const index = parseInt(deleteBtn.dataset.index, 10); 208 | this.deleteSummary(index); 209 | return; 210 | } 211 | 212 | if (link) { 213 | e.preventDefault(); 214 | state.activeSummaryIndex = parseInt(link.dataset.index, 10); 215 | state.error = null; 216 | this.render(); 217 | if (this.isMobile()) this.toggleSidebar(false); 218 | } 219 | }, 220 | 221 | deleteSummary(indexToDelete) { 222 | const summaryToDelete = state.summaries[indexToDelete]; 223 | if (!summaryToDelete) return; 224 | 225 | if (confirm(`Are you sure you want to delete the summary for "${summaryToDelete.name}"?`)) { 226 | state.summaries.splice(indexToDelete, 1); 227 | 228 | if (state.activeSummaryIndex === indexToDelete) { 229 | state.activeSummaryIndex = -1; 230 | state.error = null; // Clear error if the active (and possibly error-causing) summary is deleted 231 | } else if (state.activeSummaryIndex > indexToDelete) { 232 | state.activeSummaryIndex--; 233 | } 234 | 235 | this.saveSummaries(); 236 | } 237 | }, 238 | 239 | render() { 240 | const hasActiveSummary = state.activeSummaryIndex > -1; 241 | const currentSummary = hasActiveSummary ? state.summaries[state.activeSummaryIndex] : null; 242 | const shouldShowSummaryView = state.isLoading || hasActiveSummary || state.error; 243 | 244 | dom.welcomeView.classList.toggle('hidden', shouldShowSummaryView); 245 | dom.summaryView.classList.toggle('hidden', !shouldShowSummaryView); 246 | 247 | const hasStatus = state.isLoading || state.error; 248 | dom.statusContainer.classList.toggle('hidden', !hasStatus); 249 | dom.loader.style.display = state.isLoading ? 'flex' : 'none'; 250 | dom.errorMessage.style.display = state.error ? 'block' : 'none'; 251 | dom.errorMessage.textContent = state.error || ''; 252 | 253 | dom.summaryContainer.classList.toggle('hidden', !currentSummary || hasStatus); 254 | dom.transcriptSection.classList.toggle('hidden', true); 255 | 256 | if (currentSummary) { 257 | dom.summaryTitleText.textContent = currentSummary.name; 258 | dom.videoLink.href = currentSummary.url; 259 | dom.summaryOutput.mdContent = currentSummary.summary; 260 | if (currentSummary.transcript && currentSummary.transcript.trim()) { 261 | dom.transcriptText.textContent = currentSummary.transcript; 262 | dom.transcriptSection.classList.remove('hidden'); 263 | } 264 | } 265 | 266 | this.renderSidebarList(); 267 | if (window.lucide) { 268 | lucide.createIcons(); 269 | } 270 | }, 271 | 272 | renderSidebarList() { 273 | dom.savedSummariesList.innerHTML = state.summaries.map((summary, index) => ` 274 |
  • 275 | 276 | 277 | ${this.escapeHtml(summary.name)} 278 | 279 | 282 |
  • 283 | `).join(''); 284 | }, 285 | 286 | async handleCopyClick(e, text, button) { 287 | e.preventDefault(); 288 | e.stopPropagation(); 289 | if (!text) return; 290 | 291 | const originalIcon = button.innerHTML; 292 | const originalTitle = button.title; 293 | try { 294 | await copyToClipboard(text); 295 | button.innerHTML = ''; 296 | button.title = 'Copied!'; 297 | if (window.lucide) lucide.createIcons(); 298 | } catch (err) { 299 | console.error('Failed to copy: ', err); 300 | button.title = 'Failed to copy'; 301 | } finally { 302 | setTimeout(() => { 303 | button.innerHTML = originalIcon; 304 | button.title = originalTitle; 305 | if (window.lucide) lucide.createIcons(); 306 | }, 2000); 307 | } 308 | }, 309 | 310 | isMobile: () => window.innerWidth <= 800, 311 | 312 | toggleSidebar(force) { 313 | document.body.classList.toggle('sidebar-open', force); 314 | dom.menuToggleBtn.setAttribute('aria-expanded', document.body.classList.contains('sidebar-open')); 315 | }, 316 | 317 | escapeHtml(str) { 318 | const p = document.createElement('p'); 319 | p.textContent = str; 320 | return p.innerHTML; 321 | } 322 | }; 323 | 324 | app.init(); 325 | }); 326 | 327 | window.addEventListener('unhandledrejection', event => { 328 | console.error('Unhandled rejection:', event.reason); 329 | }); 330 | window.addEventListener('error', event => { 331 | console.error('Uncaught error:', event.error); 332 | }); 333 | 334 | // https://stackoverflow.com/a/65996386 335 | async function copyToClipboard(textToCopy) { 336 | // Navigator clipboard api needs a secure context (https) 337 | if (navigator.clipboard && window.isSecureContext) { 338 | await navigator.clipboard.writeText(textToCopy); 339 | } else { 340 | // Use the 'out of viewport hidden text area' trick 341 | const textArea = document.createElement("textarea"); 342 | textArea.value = textToCopy; 343 | 344 | // Move textarea out of the viewport so it's not visible 345 | textArea.style.position = "absolute"; 346 | textArea.style.left = "-999999px"; 347 | 348 | document.body.prepend(textArea); 349 | textArea.select(); 350 | 351 | try { 352 | document.execCommand('copy'); 353 | } catch (error) { 354 | console.error(error); 355 | } finally { 356 | textArea.remove(); 357 | } 358 | } 359 | } -------------------------------------------------------------------------------- /static/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-color: oklch(62% 0.18 280); 3 | --primary-hover: oklch(58% 0.20 280); 4 | --accent-color: oklch(70% 0.15 240); 5 | --error-color: oklch(65% 0.22 25); 6 | --primary-glow: oklch(62% 0.18 280 / 0.35); 7 | --primary-active-bg: oklch(35% 0.1 280); 8 | 9 | --text-primary: oklch(92% 0.01 280); 10 | --text-secondary: oklch(70% 0.02 280); 11 | --text-muted: oklch(55% 0.02 280); 12 | 13 | --surface-bg: oklch(18% 0.015 280); 14 | --surface-1: oklch(22% 0.018 280); 15 | --surface-2: oklch(26% 0.02 280); 16 | --surface-3: oklch(30% 0.022 280); 17 | --border-color: oklch(34% 0.02 280); 18 | 19 | --sidebar-width: 280px; 20 | --border-radius: 10px; 21 | --transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 22 | } 23 | 24 | * { 25 | box-sizing: border-box; 26 | margin: 0; 27 | padding: 0; 28 | } 29 | 30 | body { 31 | font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 32 | line-height: 1.6; 33 | background-color: var(--surface-bg); 34 | color: var(--text-primary); 35 | overflow: hidden; 36 | -webkit-font-smoothing: antialiased; 37 | -moz-osx-font-smoothing: grayscale; 38 | color-scheme: dark; 39 | scrollbar-width: thin; 40 | scrollbar-color: var(--surface-3) transparent; 41 | 42 | @supports (font-variation-settings: normal) { 43 | font-family: "InterVariable", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 44 | } 45 | } 46 | 47 | ::selection { 48 | background-color: var(--primary-active-bg); 49 | color: var(--text-primary); 50 | } 51 | 52 | input, textarea, button, select { 53 | font: inherit; 54 | } 55 | 56 | h1, h2, h3, h4 { 57 | font-weight: 600; 58 | letter-spacing: -0.02em; 59 | } 60 | 61 | /* Layout */ 62 | .app-container { 63 | display: flex; 64 | height: 100vh; 65 | } 66 | 67 | #sidebar { 68 | width: var(--sidebar-width); 69 | background: var(--surface-1); 70 | border-right: 1px solid var(--border-color); 71 | display: flex; 72 | flex-direction: column; 73 | padding: 1.5rem; 74 | 75 | @media (max-width: 800px) { 76 | position: fixed; 77 | top: 0; 78 | left: 0; 79 | height: 100%; 80 | z-index: 1001; 81 | transform: translateX(-100%); 82 | transition: transform 0.3s; 83 | } 84 | } 85 | 86 | #main-content { 87 | flex: 1; 88 | padding: 0 4rem; 89 | overflow-y: auto; 90 | display: flex; 91 | justify-content: center; 92 | align-items: flex-start; 93 | 94 | @media (max-width: 800px) { 95 | padding: 5rem 1.5rem 2rem; 96 | } 97 | } 98 | 99 | #welcome-view, #summary-view { 100 | width: 100%; 101 | max-width: 70ch; 102 | margin: 3rem 0; 103 | 104 | @media (max-width: 800px) { 105 | margin-top: 0; 106 | max-width: 100%; 107 | } 108 | } 109 | 110 | #welcome-view { 111 | header { 112 | text-align: center; 113 | margin-bottom: 3rem; 114 | 115 | @media (max-width: 800px) { 116 | margin-bottom: 2rem; 117 | } 118 | 119 | h1 { 120 | font-size: 3rem; 121 | font-weight: 700; 122 | margin-bottom: 0.75rem; 123 | background: linear-gradient(120deg, var(--accent-color) 0%, var(--primary-color) 100%); 124 | -webkit-background-clip: text; 125 | -webkit-text-fill-color: transparent; 126 | background-clip: text; 127 | 128 | @media (max-width: 800px) { 129 | font-size: 2.5rem; 130 | } 131 | } 132 | 133 | p { 134 | color: var(--text-secondary); 135 | font-size: 1.125rem; 136 | max-width: 560px; 137 | margin-inline: auto; 138 | line-height: 1.7; 139 | } 140 | } 141 | } 142 | 143 | #summary-form { 144 | display: flex; 145 | gap: 0.75rem; 146 | margin-bottom: 2rem; 147 | 148 | @media (max-width: 800px) { 149 | flex-direction: column; 150 | } 151 | 152 | input[type="url"] { 153 | flex: 1; 154 | font-size: 1rem; 155 | border-radius: var(--border-radius); 156 | background: var(--surface-2); 157 | color: var(--text-primary); 158 | padding: 0.9rem 1rem 0.9rem 2.75rem; 159 | transition: var(--transition); 160 | border: 1px solid var(--border-color); 161 | outline: none; 162 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%23817e90' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'/%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'/%3E%3C/svg%3E"); 163 | background-repeat: no-repeat; 164 | background-position: 0.8rem center; 165 | 166 | &:focus { 167 | border-color: var(--primary-color); 168 | box-shadow: 0 0 0 3px var(--primary-glow); 169 | } 170 | } 171 | 172 | button { 173 | background: var(--primary-color); 174 | color: white; 175 | font-weight: 600; 176 | border: none; 177 | cursor: pointer; 178 | padding: 0.9rem 1.5rem; 179 | border-radius: var(--border-radius); 180 | transition: var(--transition); 181 | 182 | &:hover { 183 | background: var(--primary-hover); 184 | transform: translateY(-1px); 185 | } 186 | } 187 | } 188 | 189 | /* Components */ 190 | [data-lucide] { 191 | width: 1.1em; 192 | height: 1.1em; 193 | stroke-width: 2; 194 | vertical-align: middle; 195 | pointer-events: none; 196 | flex-shrink: 0; 197 | } 198 | 199 | #new-summary-btn { 200 | width: 100%; 201 | padding: 0.8rem 1rem; 202 | font-size: 0.95rem; 203 | font-weight: 600; 204 | background: linear-gradient(135deg, var(--primary-color), var(--primary-hover)); 205 | color: white; 206 | border: none; 207 | border-radius: var(--border-radius); 208 | cursor: pointer; 209 | transition: var(--transition); 210 | display: flex; 211 | align-items: center; 212 | justify-content: center; 213 | gap: 0.5rem; 214 | box-shadow: 0 4px 15px -5px var(--primary-glow); 215 | 216 | &:hover { 217 | transform: translateY(-2px); 218 | box-shadow: 0 6px 20px -5px var(--primary-glow); 219 | } 220 | 221 | @media (max-width: 800px) { 222 | flex: 1; 223 | margin-right: 0.5rem; 224 | } 225 | } 226 | 227 | #saved-summaries-section { 228 | flex: 1; 229 | overflow-y: auto; 230 | margin: 2rem -0.5rem 0; 231 | 232 | h2 { 233 | font-size: 0.75rem; 234 | font-weight: 700; 235 | text-transform: uppercase; 236 | letter-spacing: 0.08em; 237 | color: var(--text-muted); 238 | margin-bottom: 1rem; 239 | padding: 0 1.25rem; 240 | } 241 | } 242 | 243 | #saved-summaries-list { 244 | li { 245 | display: flex; 246 | align-items: center; 247 | margin: 0.25rem 0.5rem; 248 | border-radius: var(--border-radius); 249 | transition: var(--transition); 250 | position: relative; 251 | 252 | a { 253 | display: flex; 254 | align-items: center; 255 | padding: 0.75rem 2.5rem 0.75rem 1.25rem; 256 | color: var(--text-secondary); 257 | text-decoration: none; 258 | transition: var(--transition); 259 | font-weight: 500; 260 | font-size: 0.9rem; 261 | gap: 0.75rem; 262 | flex: 1; 263 | white-space: nowrap; 264 | overflow: hidden; 265 | mask-image: linear-gradient(to right, black 80%, transparent 90%); 266 | } 267 | 268 | &:hover:not(.active) { 269 | background: var(--surface-2); 270 | 271 | a { 272 | color: var(--text-primary); 273 | } 274 | } 275 | 276 | &.active { 277 | background: var(--primary-active-bg); 278 | box-shadow: 0 0 15px -5px var(--primary-glow); 279 | 280 | a { 281 | color: var(--text-primary); 282 | font-weight: 600; 283 | } 284 | } 285 | 286 | &:hover .delete-summary-btn { 287 | opacity: 0.8; 288 | pointer-events: auto; 289 | color: var(--error-color); 290 | } 291 | } 292 | } 293 | 294 | .delete-summary-btn { 295 | position: absolute; 296 | top: 50%; 297 | right: 0.5rem; 298 | transform: translateY(-50%); 299 | background: transparent; 300 | border: none; 301 | color: var(--text-muted); 302 | cursor: pointer; 303 | padding: 0.5rem; 304 | border-radius: calc(var(--border-radius) * 0.6); 305 | transition: var(--transition); 306 | font-size: 0.85rem; 307 | line-height: 1; 308 | opacity: 0; 309 | pointer-events: none; 310 | 311 | &:hover { 312 | opacity: 1; 313 | background: oklch(65% 0.22 25 / 0.15); 314 | } 315 | } 316 | 317 | .icon-btn { 318 | background: transparent; 319 | border: none; 320 | color: var(--text-muted); 321 | cursor: pointer; 322 | padding: 0.5rem; 323 | border-radius: calc(var(--border-radius) * 0.6); 324 | transition: var(--transition); 325 | font-size: 0.95rem; 326 | line-height: 1; 327 | 328 | &:hover { 329 | color: var(--text-primary); 330 | background: var(--surface-3); 331 | } 332 | } 333 | 334 | .sidebar-footer { 335 | margin-top: 1.5rem; 336 | padding-top: 1.5rem; 337 | border-top: 1px solid var(--border-color); 338 | } 339 | 340 | #clear-summaries-btn { 341 | width: 100%; 342 | padding: 0.75rem; 343 | font-size: 0.9rem; 344 | font-weight: 500; 345 | background: transparent; 346 | color: var(--text-muted); 347 | border: 1px solid var(--border-color); 348 | border-radius: var(--border-radius); 349 | cursor: pointer; 350 | transition: var(--transition); 351 | display: flex; 352 | align-items: center; 353 | justify-content: center; 354 | gap: 0.5rem; 355 | 356 | &:hover { 357 | background: oklch(65% 0.25 25 / 0.1); 358 | color: var(--error-color); 359 | border-color: oklch(65% 0.25 25 / 0.5); 360 | } 361 | } 362 | 363 | .settings-details, #transcript-details { 364 | border: 1px solid var(--border-color); 365 | border-radius: calc(var(--border-radius) * 1.6); 366 | background: var(--surface-1); 367 | overflow: hidden; 368 | 369 | summary { 370 | padding: 1.25rem; 371 | cursor: pointer; 372 | font-weight: 500; 373 | display: flex; 374 | align-items: center; 375 | justify-content: space-between; 376 | list-style: none; 377 | transition: var(--transition); 378 | 379 | &:hover { 380 | background: var(--surface-2); 381 | } 382 | } 383 | 384 | .chevron { 385 | color: var(--text-muted); 386 | transition: transform 0.3s; 387 | } 388 | 389 | &[open] { 390 | & > summary { 391 | border-bottom: 1px solid var(--border-color); 392 | } 393 | 394 | .chevron { 395 | transform: rotate(180deg); 396 | } 397 | } 398 | } 399 | 400 | .settings-details { 401 | margin-top: 2rem; 402 | } 403 | 404 | .settings-content { 405 | padding: 1.5rem; 406 | display: flex; 407 | flex-direction: column; 408 | gap: 1.5rem; 409 | 410 | & > div { 411 | display: flex; 412 | flex-direction: column; 413 | gap: 0.5rem; 414 | } 415 | 416 | input[type="text"], input[type="password"], textarea { 417 | width: 100%; 418 | font-size: 0.95rem; 419 | background: var(--surface-2); 420 | color: var(--text-primary); 421 | padding: 0.75rem 1rem; 422 | border: 1px solid var(--border-color); 423 | border-radius: var(--border-radius); 424 | transition: var(--transition); 425 | outline: none; 426 | 427 | &:focus { 428 | border-color: var(--primary-color); 429 | box-shadow: 0 0 0 3px var(--primary-glow); 430 | } 431 | } 432 | 433 | textarea { 434 | resize: vertical; 435 | min-height: 100px; 436 | line-height: 1.6; 437 | } 438 | 439 | .checkbox-group { 440 | flex-direction: row; 441 | align-items: center; 442 | 443 | label { 444 | user-select: none; 445 | cursor: pointer; 446 | display: flex; 447 | align-items: center; 448 | gap: 0.25rem; 449 | color: var(--text-secondary); 450 | font-weight: 500; 451 | font-size: 0.95rem; 452 | } 453 | } 454 | 455 | input[type="checkbox"] { 456 | appearance: none; 457 | width: 1.35em; 458 | height: 1.35em; 459 | border: 1px solid var(--border-color); 460 | background: var(--surface-2); 461 | border-radius: calc(var(--border-radius) * 0.6); 462 | cursor: pointer; 463 | position: relative; 464 | transition: var(--transition); 465 | 466 | &::before { 467 | content: ""; 468 | position: absolute; 469 | top: 50%; 470 | left: 50%; 471 | width: 0.8em; 472 | height: 0.8em; 473 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='20 6 9 17 4 12'/%3e%3c/svg%3e"); 474 | background-position: center; 475 | background-repeat: no-repeat; 476 | background-size: 100%; 477 | transform: translate(-50%, -50%) scale(0); 478 | transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); 479 | } 480 | 481 | &:checked { 482 | border-color: var(--primary-color); 483 | background: var(--primary-color); 484 | 485 | &::before { 486 | transform: translate(-50%, -50%) scale(1); 487 | } 488 | } 489 | 490 | &:hover:not(:checked) { 491 | border-color: var(--text-secondary); 492 | } 493 | } 494 | } 495 | 496 | #summary-container { 497 | background: var(--surface-1); 498 | border: 1px solid var(--border-color); 499 | border-radius: calc(var(--border-radius) * 1.6); 500 | padding: 2.5rem; 501 | 502 | @media (max-width: 800px) { 503 | padding: 1.5rem; 504 | } 505 | } 506 | 507 | #summary-title { 508 | font-size: 1.75rem; 509 | font-weight: 700; 510 | padding: 0 1rem 1.25rem; 511 | margin-bottom: 1.5rem; 512 | border-bottom: 1px solid var(--border-color); 513 | display: flex; 514 | align-items: center; 515 | 516 | @media (max-width: 800px) { 517 | font-size: 1.5rem; 518 | padding: 0 0 1rem; 519 | margin-bottom: 1rem; 520 | } 521 | 522 | #summary-title-text { 523 | flex: 1; 524 | text-align: center; 525 | margin: 0 1rem; 526 | } 527 | 528 | [data-lucide] { 529 | color: var(--primary-color); 530 | width: 1.3em; 531 | height: 1.3em; 532 | } 533 | } 534 | 535 | md-block { 536 | font-size: 1.05rem; 537 | line-height: 1.8; 538 | color: var(--text-secondary); 539 | 540 | & > * + * { 541 | margin-top: 1.25em; 542 | } 543 | 544 | h1, h2, h3, h4 { 545 | color: var(--text-primary); 546 | line-height: 1.3; 547 | margin-bottom: 0.75em; 548 | } 549 | 550 | h2 { 551 | padding-bottom: 0.4em; 552 | border-bottom: 1px solid var(--border-color); 553 | } 554 | 555 | a { 556 | color: var(--primary-color); 557 | text-decoration: none; 558 | font-weight: 500; 559 | transition: var(--transition); 560 | border-bottom: 1px solid transparent; 561 | 562 | &:hover { 563 | color: var(--accent-color); 564 | border-bottom-color: var(--accent-color); 565 | } 566 | } 567 | 568 | code { 569 | background: var(--surface-2); 570 | padding: 0.25em 0.5em; 571 | border-radius: calc(var(--border-radius) * 0.6); 572 | font-family: 'JetBrains Mono', monospace; 573 | font-size: 0.9em; 574 | color: var(--text-primary); 575 | border: 1px solid var(--border-color); 576 | } 577 | 578 | pre { 579 | background: var(--surface-bg); 580 | padding: 1.25rem; 581 | border-radius: var(--border-radius); 582 | overflow-x: auto; 583 | border: 1px solid var(--border-color); 584 | font-size: 0.9rem; 585 | 586 | code { 587 | background: none; 588 | padding: 0; 589 | border: none; 590 | } 591 | } 592 | 593 | blockquote { 594 | border-left: 3px solid var(--primary-color); 595 | padding-left: 1.5rem; 596 | font-style: italic; 597 | color: var(--text-muted); 598 | } 599 | 600 | ul, ol { 601 | padding-left: 1.5rem; 602 | } 603 | 604 | li::marker { 605 | color: var(--primary-color); 606 | font-weight: 600; 607 | } 608 | 609 | hr { 610 | border: none; 611 | height: 1px; 612 | background: var(--border-color); 613 | } 614 | } 615 | 616 | #transcript-section { 617 | margin-top: 2rem; 618 | border-top: 1px solid var(--border-color); 619 | padding-top: 2rem; 620 | } 621 | 622 | #transcript-details { 623 | summary { 624 | font-size: 1.1rem; 625 | 626 | @media (max-width: 800px) { 627 | font-size: 1rem; 628 | padding: 1rem; 629 | } 630 | 631 | & > span:first-child { 632 | display: flex; 633 | align-items: center; 634 | gap: 0.75rem; 635 | } 636 | } 637 | 638 | .summary-actions { 639 | display: flex; 640 | align-items: center; 641 | gap: 0.75rem; 642 | } 643 | } 644 | 645 | #transcript-text { 646 | background: var(--surface-bg); 647 | padding: 1.5rem; 648 | margin: 0; 649 | max-height: 300px; 650 | overflow-y: auto; 651 | color: var(--text-muted); 652 | font-size: 0.95rem; 653 | line-height: 1.7; 654 | white-space: pre-wrap; 655 | word-wrap: break-word; 656 | } 657 | 658 | #status-container { 659 | padding: 3rem 0; 660 | text-align: center; 661 | } 662 | 663 | #loader { 664 | flex-direction: column; 665 | align-items: center; 666 | gap: 1.5rem; 667 | color: var(--text-secondary); 668 | font-size: 1.1rem; 669 | font-weight: 500; 670 | } 671 | 672 | .spinner { 673 | width: 50px; 674 | height: 50px; 675 | border-radius: 50%; 676 | border: 5px solid var(--surface-3); 677 | border-top-color: var(--primary-color); 678 | animation: spin 1s ease-in-out infinite; 679 | } 680 | 681 | @keyframes spin { 682 | to { transform: rotate(360deg); } 683 | } 684 | 685 | #error-message { 686 | color: var(--error-color); 687 | font-weight: 500; 688 | background: oklch(65% 0.22 25 / 0.1); 689 | padding: 1rem 1.5rem; 690 | border-radius: var(--border-radius); 691 | border: 1px solid oklch(65% 0.22 25 / 0.3); 692 | } 693 | 694 | /* Mobile Menu */ 695 | #menu-toggle-btn { 696 | display: none; 697 | position: fixed; 698 | top: 1rem; 699 | left: 1rem; 700 | z-index: 1002; 701 | background: var(--surface-2); 702 | border: 1px solid var(--border-color); 703 | color: var(--text-primary); 704 | width: 44px; 705 | height: 44px; 706 | border-radius: var(--border-radius); 707 | font-size: 1.2rem; 708 | cursor: pointer; 709 | transition: var(--transition); 710 | 711 | &:hover { 712 | background: var(--surface-3); 713 | border-color: var(--primary-color); 714 | } 715 | 716 | @media (max-width: 800px) { 717 | display: flex; 718 | align-items: center; 719 | justify-content: center; 720 | } 721 | } 722 | 723 | body.sidebar-open { 724 | #menu-toggle-btn { 725 | opacity: 0; 726 | pointer-events: none; 727 | transition: opacity 0.1s; 728 | } 729 | 730 | @media (max-width: 800px) { 731 | #sidebar { 732 | transform: translateX(0); 733 | } 734 | 735 | #sidebar-overlay { 736 | display: block; 737 | } 738 | } 739 | } 740 | 741 | #close-sidebar-btn { 742 | display: none; 743 | 744 | @media (max-width: 800px) { 745 | display: block; 746 | padding: 0.8rem; 747 | } 748 | } 749 | 750 | .sidebar-header { 751 | @media (max-width: 800px) { 752 | display: flex; 753 | justify-content: space-between; 754 | align-items: center; 755 | } 756 | } 757 | 758 | #sidebar-overlay { 759 | display: none; 760 | position: fixed; 761 | top: 0; 762 | left: 0; 763 | width: 100%; 764 | height: 100%; 765 | background: oklch(0 0 0 / 0.5); 766 | z-index: 1000; 767 | } 768 | 769 | .hidden { 770 | display: none !important; 771 | } -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "YouTubeTLDR" 7 | version = "1.6.0" 8 | dependencies = [ 9 | "flate2", 10 | "flume", 11 | "minifier", 12 | "miniserde", 13 | "minreq", 14 | ] 15 | 16 | [[package]] 17 | name = "adler2" 18 | version = "2.0.1" 19 | source = "registry+https://github.com/rust-lang/crates.io-index" 20 | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 21 | 22 | [[package]] 23 | name = "aho-corasick" 24 | version = "1.1.4" 25 | source = "registry+https://github.com/rust-lang/crates.io-index" 26 | checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" 27 | dependencies = [ 28 | "memchr", 29 | ] 30 | 31 | [[package]] 32 | name = "anstream" 33 | version = "0.6.21" 34 | source = "registry+https://github.com/rust-lang/crates.io-index" 35 | checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" 36 | dependencies = [ 37 | "anstyle", 38 | "anstyle-parse", 39 | "anstyle-query", 40 | "anstyle-wincon", 41 | "colorchoice", 42 | "is_terminal_polyfill", 43 | "utf8parse", 44 | ] 45 | 46 | [[package]] 47 | name = "anstyle" 48 | version = "1.0.13" 49 | source = "registry+https://github.com/rust-lang/crates.io-index" 50 | checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" 51 | 52 | [[package]] 53 | name = "anstyle-parse" 54 | version = "0.2.7" 55 | source = "registry+https://github.com/rust-lang/crates.io-index" 56 | checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" 57 | dependencies = [ 58 | "utf8parse", 59 | ] 60 | 61 | [[package]] 62 | name = "anstyle-query" 63 | version = "1.1.5" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" 66 | dependencies = [ 67 | "windows-sys 0.61.2", 68 | ] 69 | 70 | [[package]] 71 | name = "anstyle-wincon" 72 | version = "3.0.11" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" 75 | dependencies = [ 76 | "anstyle", 77 | "once_cell_polyfill", 78 | "windows-sys 0.61.2", 79 | ] 80 | 81 | [[package]] 82 | name = "aws-lc-rs" 83 | version = "1.15.0" 84 | source = "registry+https://github.com/rust-lang/crates.io-index" 85 | checksum = "5932a7d9d28b0d2ea34c6b3779d35e3dd6f6345317c34e73438c4f1f29144151" 86 | dependencies = [ 87 | "aws-lc-sys", 88 | "zeroize", 89 | ] 90 | 91 | [[package]] 92 | name = "aws-lc-sys" 93 | version = "0.33.0" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "1826f2e4cfc2cd19ee53c42fbf68e2f81ec21108e0b7ecf6a71cf062137360fc" 96 | dependencies = [ 97 | "bindgen", 98 | "cc", 99 | "cmake", 100 | "dunce", 101 | "fs_extra", 102 | ] 103 | 104 | [[package]] 105 | name = "base64" 106 | version = "0.22.1" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 109 | 110 | [[package]] 111 | name = "bindgen" 112 | version = "0.72.1" 113 | source = "registry+https://github.com/rust-lang/crates.io-index" 114 | checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" 115 | dependencies = [ 116 | "bitflags", 117 | "cexpr", 118 | "clang-sys", 119 | "itertools", 120 | "log", 121 | "prettyplease", 122 | "proc-macro2", 123 | "quote", 124 | "regex", 125 | "rustc-hash", 126 | "shlex", 127 | "syn", 128 | ] 129 | 130 | [[package]] 131 | name = "bitflags" 132 | version = "2.10.0" 133 | source = "registry+https://github.com/rust-lang/crates.io-index" 134 | checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" 135 | 136 | [[package]] 137 | name = "bumpalo" 138 | version = "3.19.0" 139 | source = "registry+https://github.com/rust-lang/crates.io-index" 140 | checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 141 | 142 | [[package]] 143 | name = "cc" 144 | version = "1.2.46" 145 | source = "registry+https://github.com/rust-lang/crates.io-index" 146 | checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" 147 | dependencies = [ 148 | "find-msvc-tools", 149 | "jobserver", 150 | "libc", 151 | "shlex", 152 | ] 153 | 154 | [[package]] 155 | name = "cexpr" 156 | version = "0.6.0" 157 | source = "registry+https://github.com/rust-lang/crates.io-index" 158 | checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" 159 | dependencies = [ 160 | "nom", 161 | ] 162 | 163 | [[package]] 164 | name = "cfg-if" 165 | version = "1.0.4" 166 | source = "registry+https://github.com/rust-lang/crates.io-index" 167 | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 168 | 169 | [[package]] 170 | name = "clang-sys" 171 | version = "1.8.1" 172 | source = "registry+https://github.com/rust-lang/crates.io-index" 173 | checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" 174 | dependencies = [ 175 | "glob", 176 | "libc", 177 | "libloading", 178 | ] 179 | 180 | [[package]] 181 | name = "clap" 182 | version = "4.5.51" 183 | source = "registry+https://github.com/rust-lang/crates.io-index" 184 | checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" 185 | dependencies = [ 186 | "clap_builder", 187 | ] 188 | 189 | [[package]] 190 | name = "clap_builder" 191 | version = "4.5.51" 192 | source = "registry+https://github.com/rust-lang/crates.io-index" 193 | checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" 194 | dependencies = [ 195 | "anstream", 196 | "anstyle", 197 | "clap_lex", 198 | "strsim", 199 | ] 200 | 201 | [[package]] 202 | name = "clap_lex" 203 | version = "0.7.6" 204 | source = "registry+https://github.com/rust-lang/crates.io-index" 205 | checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" 206 | 207 | [[package]] 208 | name = "cmake" 209 | version = "0.1.54" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" 212 | dependencies = [ 213 | "cc", 214 | ] 215 | 216 | [[package]] 217 | name = "colorchoice" 218 | version = "1.0.4" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 221 | 222 | [[package]] 223 | name = "core-foundation" 224 | version = "0.9.4" 225 | source = "registry+https://github.com/rust-lang/crates.io-index" 226 | checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 227 | dependencies = [ 228 | "core-foundation-sys", 229 | "libc", 230 | ] 231 | 232 | [[package]] 233 | name = "core-foundation-sys" 234 | version = "0.8.7" 235 | source = "registry+https://github.com/rust-lang/crates.io-index" 236 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 237 | 238 | [[package]] 239 | name = "crc32fast" 240 | version = "1.5.0" 241 | source = "registry+https://github.com/rust-lang/crates.io-index" 242 | checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" 243 | dependencies = [ 244 | "cfg-if", 245 | ] 246 | 247 | [[package]] 248 | name = "dunce" 249 | version = "1.0.5" 250 | source = "registry+https://github.com/rust-lang/crates.io-index" 251 | checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" 252 | 253 | [[package]] 254 | name = "either" 255 | version = "1.15.0" 256 | source = "registry+https://github.com/rust-lang/crates.io-index" 257 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 258 | 259 | [[package]] 260 | name = "errno" 261 | version = "0.3.14" 262 | source = "registry+https://github.com/rust-lang/crates.io-index" 263 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 264 | dependencies = [ 265 | "libc", 266 | "windows-sys 0.61.2", 267 | ] 268 | 269 | [[package]] 270 | name = "fastrand" 271 | version = "2.3.0" 272 | source = "registry+https://github.com/rust-lang/crates.io-index" 273 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 274 | 275 | [[package]] 276 | name = "find-msvc-tools" 277 | version = "0.1.5" 278 | source = "registry+https://github.com/rust-lang/crates.io-index" 279 | checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" 280 | 281 | [[package]] 282 | name = "flate2" 283 | version = "1.1.5" 284 | source = "registry+https://github.com/rust-lang/crates.io-index" 285 | checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" 286 | dependencies = [ 287 | "crc32fast", 288 | "miniz_oxide", 289 | ] 290 | 291 | [[package]] 292 | name = "flume" 293 | version = "0.11.1" 294 | source = "registry+https://github.com/rust-lang/crates.io-index" 295 | checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" 296 | dependencies = [ 297 | "futures-core", 298 | "futures-sink", 299 | "nanorand", 300 | "spin", 301 | ] 302 | 303 | [[package]] 304 | name = "foreign-types" 305 | version = "0.3.2" 306 | source = "registry+https://github.com/rust-lang/crates.io-index" 307 | checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" 308 | dependencies = [ 309 | "foreign-types-shared", 310 | ] 311 | 312 | [[package]] 313 | name = "foreign-types-shared" 314 | version = "0.1.1" 315 | source = "registry+https://github.com/rust-lang/crates.io-index" 316 | checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" 317 | 318 | [[package]] 319 | name = "fs_extra" 320 | version = "1.3.0" 321 | source = "registry+https://github.com/rust-lang/crates.io-index" 322 | checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" 323 | 324 | [[package]] 325 | name = "futures-core" 326 | version = "0.3.31" 327 | source = "registry+https://github.com/rust-lang/crates.io-index" 328 | checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 329 | 330 | [[package]] 331 | name = "futures-sink" 332 | version = "0.3.31" 333 | source = "registry+https://github.com/rust-lang/crates.io-index" 334 | checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" 335 | 336 | [[package]] 337 | name = "getrandom" 338 | version = "0.2.16" 339 | source = "registry+https://github.com/rust-lang/crates.io-index" 340 | checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 341 | dependencies = [ 342 | "cfg-if", 343 | "js-sys", 344 | "libc", 345 | "wasi", 346 | "wasm-bindgen", 347 | ] 348 | 349 | [[package]] 350 | name = "getrandom" 351 | version = "0.3.4" 352 | source = "registry+https://github.com/rust-lang/crates.io-index" 353 | checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 354 | dependencies = [ 355 | "cfg-if", 356 | "libc", 357 | "r-efi", 358 | "wasip2", 359 | ] 360 | 361 | [[package]] 362 | name = "glob" 363 | version = "0.3.3" 364 | source = "registry+https://github.com/rust-lang/crates.io-index" 365 | checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" 366 | 367 | [[package]] 368 | name = "is_terminal_polyfill" 369 | version = "1.70.2" 370 | source = "registry+https://github.com/rust-lang/crates.io-index" 371 | checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 372 | 373 | [[package]] 374 | name = "itertools" 375 | version = "0.13.0" 376 | source = "registry+https://github.com/rust-lang/crates.io-index" 377 | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 378 | dependencies = [ 379 | "either", 380 | ] 381 | 382 | [[package]] 383 | name = "itoa" 384 | version = "1.0.15" 385 | source = "registry+https://github.com/rust-lang/crates.io-index" 386 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 387 | 388 | [[package]] 389 | name = "jobserver" 390 | version = "0.1.34" 391 | source = "registry+https://github.com/rust-lang/crates.io-index" 392 | checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" 393 | dependencies = [ 394 | "getrandom 0.3.4", 395 | "libc", 396 | ] 397 | 398 | [[package]] 399 | name = "js-sys" 400 | version = "0.3.82" 401 | source = "registry+https://github.com/rust-lang/crates.io-index" 402 | checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" 403 | dependencies = [ 404 | "once_cell", 405 | "wasm-bindgen", 406 | ] 407 | 408 | [[package]] 409 | name = "libc" 410 | version = "0.2.177" 411 | source = "registry+https://github.com/rust-lang/crates.io-index" 412 | checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" 413 | 414 | [[package]] 415 | name = "libloading" 416 | version = "0.8.9" 417 | source = "registry+https://github.com/rust-lang/crates.io-index" 418 | checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" 419 | dependencies = [ 420 | "cfg-if", 421 | "windows-link", 422 | ] 423 | 424 | [[package]] 425 | name = "linux-raw-sys" 426 | version = "0.11.0" 427 | source = "registry+https://github.com/rust-lang/crates.io-index" 428 | checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" 429 | 430 | [[package]] 431 | name = "lock_api" 432 | version = "0.4.14" 433 | source = "registry+https://github.com/rust-lang/crates.io-index" 434 | checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" 435 | dependencies = [ 436 | "scopeguard", 437 | ] 438 | 439 | [[package]] 440 | name = "log" 441 | version = "0.4.28" 442 | source = "registry+https://github.com/rust-lang/crates.io-index" 443 | checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 444 | 445 | [[package]] 446 | name = "memchr" 447 | version = "2.7.6" 448 | source = "registry+https://github.com/rust-lang/crates.io-index" 449 | checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 450 | 451 | [[package]] 452 | name = "mini-internal" 453 | version = "0.1.43" 454 | source = "git+https://github.com/Milkshiift/miniserde#23805db6e221463b5b8e904f20215e0772c44fc4" 455 | dependencies = [ 456 | "proc-macro2", 457 | "quote", 458 | "syn", 459 | ] 460 | 461 | [[package]] 462 | name = "minifier" 463 | version = "0.3.6" 464 | source = "registry+https://github.com/rust-lang/crates.io-index" 465 | checksum = "14f1541610994bba178cb36757e102d06a52a2d9612aa6d34c64b3b377c5d943" 466 | dependencies = [ 467 | "clap", 468 | ] 469 | 470 | [[package]] 471 | name = "minimal-lexical" 472 | version = "0.2.1" 473 | source = "registry+https://github.com/rust-lang/crates.io-index" 474 | checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" 475 | 476 | [[package]] 477 | name = "miniserde" 478 | version = "0.1.43" 479 | source = "git+https://github.com/Milkshiift/miniserde#23805db6e221463b5b8e904f20215e0772c44fc4" 480 | dependencies = [ 481 | "itoa", 482 | "mini-internal", 483 | "ryu", 484 | ] 485 | 486 | [[package]] 487 | name = "miniz_oxide" 488 | version = "0.8.9" 489 | source = "registry+https://github.com/rust-lang/crates.io-index" 490 | checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" 491 | dependencies = [ 492 | "adler2", 493 | "simd-adler32", 494 | ] 495 | 496 | [[package]] 497 | name = "minreq" 498 | version = "2.14.2-alpha" 499 | source = "git+https://github.com/Milkshiift/minreq#8833745d5410b32f9aa458a18671fe3b033e941c" 500 | dependencies = [ 501 | "base64", 502 | "native-tls", 503 | "rustls", 504 | "rustls-webpki", 505 | "webpki-roots", 506 | ] 507 | 508 | [[package]] 509 | name = "nanorand" 510 | version = "0.7.0" 511 | source = "registry+https://github.com/rust-lang/crates.io-index" 512 | checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" 513 | dependencies = [ 514 | "getrandom 0.2.16", 515 | ] 516 | 517 | [[package]] 518 | name = "native-tls" 519 | version = "0.2.14" 520 | source = "registry+https://github.com/rust-lang/crates.io-index" 521 | checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" 522 | dependencies = [ 523 | "libc", 524 | "log", 525 | "openssl", 526 | "openssl-probe", 527 | "openssl-sys", 528 | "schannel", 529 | "security-framework", 530 | "security-framework-sys", 531 | "tempfile", 532 | ] 533 | 534 | [[package]] 535 | name = "nom" 536 | version = "7.1.3" 537 | source = "registry+https://github.com/rust-lang/crates.io-index" 538 | checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" 539 | dependencies = [ 540 | "memchr", 541 | "minimal-lexical", 542 | ] 543 | 544 | [[package]] 545 | name = "once_cell" 546 | version = "1.21.3" 547 | source = "registry+https://github.com/rust-lang/crates.io-index" 548 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 549 | 550 | [[package]] 551 | name = "once_cell_polyfill" 552 | version = "1.70.2" 553 | source = "registry+https://github.com/rust-lang/crates.io-index" 554 | checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 555 | 556 | [[package]] 557 | name = "openssl" 558 | version = "0.10.75" 559 | source = "registry+https://github.com/rust-lang/crates.io-index" 560 | checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" 561 | dependencies = [ 562 | "bitflags", 563 | "cfg-if", 564 | "foreign-types", 565 | "libc", 566 | "once_cell", 567 | "openssl-macros", 568 | "openssl-sys", 569 | ] 570 | 571 | [[package]] 572 | name = "openssl-macros" 573 | version = "0.1.1" 574 | source = "registry+https://github.com/rust-lang/crates.io-index" 575 | checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" 576 | dependencies = [ 577 | "proc-macro2", 578 | "quote", 579 | "syn", 580 | ] 581 | 582 | [[package]] 583 | name = "openssl-probe" 584 | version = "0.1.6" 585 | source = "registry+https://github.com/rust-lang/crates.io-index" 586 | checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" 587 | 588 | [[package]] 589 | name = "openssl-sys" 590 | version = "0.9.111" 591 | source = "registry+https://github.com/rust-lang/crates.io-index" 592 | checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" 593 | dependencies = [ 594 | "cc", 595 | "libc", 596 | "pkg-config", 597 | "vcpkg", 598 | ] 599 | 600 | [[package]] 601 | name = "pkg-config" 602 | version = "0.3.32" 603 | source = "registry+https://github.com/rust-lang/crates.io-index" 604 | checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 605 | 606 | [[package]] 607 | name = "prettyplease" 608 | version = "0.2.37" 609 | source = "registry+https://github.com/rust-lang/crates.io-index" 610 | checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" 611 | dependencies = [ 612 | "proc-macro2", 613 | "syn", 614 | ] 615 | 616 | [[package]] 617 | name = "proc-macro2" 618 | version = "1.0.103" 619 | source = "registry+https://github.com/rust-lang/crates.io-index" 620 | checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" 621 | dependencies = [ 622 | "unicode-ident", 623 | ] 624 | 625 | [[package]] 626 | name = "quote" 627 | version = "1.0.42" 628 | source = "registry+https://github.com/rust-lang/crates.io-index" 629 | checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" 630 | dependencies = [ 631 | "proc-macro2", 632 | ] 633 | 634 | [[package]] 635 | name = "r-efi" 636 | version = "5.3.0" 637 | source = "registry+https://github.com/rust-lang/crates.io-index" 638 | checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 639 | 640 | [[package]] 641 | name = "regex" 642 | version = "1.12.2" 643 | source = "registry+https://github.com/rust-lang/crates.io-index" 644 | checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" 645 | dependencies = [ 646 | "aho-corasick", 647 | "memchr", 648 | "regex-automata", 649 | "regex-syntax", 650 | ] 651 | 652 | [[package]] 653 | name = "regex-automata" 654 | version = "0.4.13" 655 | source = "registry+https://github.com/rust-lang/crates.io-index" 656 | checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" 657 | dependencies = [ 658 | "aho-corasick", 659 | "memchr", 660 | "regex-syntax", 661 | ] 662 | 663 | [[package]] 664 | name = "regex-syntax" 665 | version = "0.8.8" 666 | source = "registry+https://github.com/rust-lang/crates.io-index" 667 | checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" 668 | 669 | [[package]] 670 | name = "ring" 671 | version = "0.17.14" 672 | source = "registry+https://github.com/rust-lang/crates.io-index" 673 | checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" 674 | dependencies = [ 675 | "cc", 676 | "cfg-if", 677 | "getrandom 0.2.16", 678 | "libc", 679 | "untrusted", 680 | "windows-sys 0.52.0", 681 | ] 682 | 683 | [[package]] 684 | name = "rustc-hash" 685 | version = "2.1.1" 686 | source = "registry+https://github.com/rust-lang/crates.io-index" 687 | checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" 688 | 689 | [[package]] 690 | name = "rustix" 691 | version = "1.1.2" 692 | source = "registry+https://github.com/rust-lang/crates.io-index" 693 | checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" 694 | dependencies = [ 695 | "bitflags", 696 | "errno", 697 | "libc", 698 | "linux-raw-sys", 699 | "windows-sys 0.61.2", 700 | ] 701 | 702 | [[package]] 703 | name = "rustls" 704 | version = "0.23.35" 705 | source = "registry+https://github.com/rust-lang/crates.io-index" 706 | checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" 707 | dependencies = [ 708 | "aws-lc-rs", 709 | "log", 710 | "once_cell", 711 | "rustls-pki-types", 712 | "rustls-webpki", 713 | "subtle", 714 | "zeroize", 715 | ] 716 | 717 | [[package]] 718 | name = "rustls-pki-types" 719 | version = "1.13.0" 720 | source = "registry+https://github.com/rust-lang/crates.io-index" 721 | checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" 722 | dependencies = [ 723 | "zeroize", 724 | ] 725 | 726 | [[package]] 727 | name = "rustls-webpki" 728 | version = "0.103.8" 729 | source = "registry+https://github.com/rust-lang/crates.io-index" 730 | checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" 731 | dependencies = [ 732 | "aws-lc-rs", 733 | "ring", 734 | "rustls-pki-types", 735 | "untrusted", 736 | ] 737 | 738 | [[package]] 739 | name = "rustversion" 740 | version = "1.0.22" 741 | source = "registry+https://github.com/rust-lang/crates.io-index" 742 | checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 743 | 744 | [[package]] 745 | name = "ryu" 746 | version = "1.0.20" 747 | source = "registry+https://github.com/rust-lang/crates.io-index" 748 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 749 | 750 | [[package]] 751 | name = "schannel" 752 | version = "0.1.28" 753 | source = "registry+https://github.com/rust-lang/crates.io-index" 754 | checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" 755 | dependencies = [ 756 | "windows-sys 0.61.2", 757 | ] 758 | 759 | [[package]] 760 | name = "scopeguard" 761 | version = "1.2.0" 762 | source = "registry+https://github.com/rust-lang/crates.io-index" 763 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 764 | 765 | [[package]] 766 | name = "security-framework" 767 | version = "2.11.1" 768 | source = "registry+https://github.com/rust-lang/crates.io-index" 769 | checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" 770 | dependencies = [ 771 | "bitflags", 772 | "core-foundation", 773 | "core-foundation-sys", 774 | "libc", 775 | "security-framework-sys", 776 | ] 777 | 778 | [[package]] 779 | name = "security-framework-sys" 780 | version = "2.15.0" 781 | source = "registry+https://github.com/rust-lang/crates.io-index" 782 | checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" 783 | dependencies = [ 784 | "core-foundation-sys", 785 | "libc", 786 | ] 787 | 788 | [[package]] 789 | name = "shlex" 790 | version = "1.3.0" 791 | source = "registry+https://github.com/rust-lang/crates.io-index" 792 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 793 | 794 | [[package]] 795 | name = "simd-adler32" 796 | version = "0.3.7" 797 | source = "registry+https://github.com/rust-lang/crates.io-index" 798 | checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" 799 | 800 | [[package]] 801 | name = "spin" 802 | version = "0.9.8" 803 | source = "registry+https://github.com/rust-lang/crates.io-index" 804 | checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 805 | dependencies = [ 806 | "lock_api", 807 | ] 808 | 809 | [[package]] 810 | name = "strsim" 811 | version = "0.11.1" 812 | source = "registry+https://github.com/rust-lang/crates.io-index" 813 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 814 | 815 | [[package]] 816 | name = "subtle" 817 | version = "2.6.1" 818 | source = "registry+https://github.com/rust-lang/crates.io-index" 819 | checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 820 | 821 | [[package]] 822 | name = "syn" 823 | version = "2.0.110" 824 | source = "registry+https://github.com/rust-lang/crates.io-index" 825 | checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" 826 | dependencies = [ 827 | "proc-macro2", 828 | "quote", 829 | "unicode-ident", 830 | ] 831 | 832 | [[package]] 833 | name = "tempfile" 834 | version = "3.23.0" 835 | source = "registry+https://github.com/rust-lang/crates.io-index" 836 | checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" 837 | dependencies = [ 838 | "fastrand", 839 | "getrandom 0.3.4", 840 | "once_cell", 841 | "rustix", 842 | "windows-sys 0.61.2", 843 | ] 844 | 845 | [[package]] 846 | name = "unicode-ident" 847 | version = "1.0.22" 848 | source = "registry+https://github.com/rust-lang/crates.io-index" 849 | checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" 850 | 851 | [[package]] 852 | name = "untrusted" 853 | version = "0.9.0" 854 | source = "registry+https://github.com/rust-lang/crates.io-index" 855 | checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 856 | 857 | [[package]] 858 | name = "utf8parse" 859 | version = "0.2.2" 860 | source = "registry+https://github.com/rust-lang/crates.io-index" 861 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 862 | 863 | [[package]] 864 | name = "vcpkg" 865 | version = "0.2.15" 866 | source = "registry+https://github.com/rust-lang/crates.io-index" 867 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 868 | 869 | [[package]] 870 | name = "wasi" 871 | version = "0.11.1+wasi-snapshot-preview1" 872 | source = "registry+https://github.com/rust-lang/crates.io-index" 873 | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 874 | 875 | [[package]] 876 | name = "wasip2" 877 | version = "1.0.1+wasi-0.2.4" 878 | source = "registry+https://github.com/rust-lang/crates.io-index" 879 | checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" 880 | dependencies = [ 881 | "wit-bindgen", 882 | ] 883 | 884 | [[package]] 885 | name = "wasm-bindgen" 886 | version = "0.2.105" 887 | source = "registry+https://github.com/rust-lang/crates.io-index" 888 | checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" 889 | dependencies = [ 890 | "cfg-if", 891 | "once_cell", 892 | "rustversion", 893 | "wasm-bindgen-macro", 894 | "wasm-bindgen-shared", 895 | ] 896 | 897 | [[package]] 898 | name = "wasm-bindgen-macro" 899 | version = "0.2.105" 900 | source = "registry+https://github.com/rust-lang/crates.io-index" 901 | checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" 902 | dependencies = [ 903 | "quote", 904 | "wasm-bindgen-macro-support", 905 | ] 906 | 907 | [[package]] 908 | name = "wasm-bindgen-macro-support" 909 | version = "0.2.105" 910 | source = "registry+https://github.com/rust-lang/crates.io-index" 911 | checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" 912 | dependencies = [ 913 | "bumpalo", 914 | "proc-macro2", 915 | "quote", 916 | "syn", 917 | "wasm-bindgen-shared", 918 | ] 919 | 920 | [[package]] 921 | name = "wasm-bindgen-shared" 922 | version = "0.2.105" 923 | source = "registry+https://github.com/rust-lang/crates.io-index" 924 | checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" 925 | dependencies = [ 926 | "unicode-ident", 927 | ] 928 | 929 | [[package]] 930 | name = "webpki-roots" 931 | version = "1.0.4" 932 | source = "registry+https://github.com/rust-lang/crates.io-index" 933 | checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" 934 | dependencies = [ 935 | "rustls-pki-types", 936 | ] 937 | 938 | [[package]] 939 | name = "windows-link" 940 | version = "0.2.1" 941 | source = "registry+https://github.com/rust-lang/crates.io-index" 942 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 943 | 944 | [[package]] 945 | name = "windows-sys" 946 | version = "0.52.0" 947 | source = "registry+https://github.com/rust-lang/crates.io-index" 948 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 949 | dependencies = [ 950 | "windows-targets", 951 | ] 952 | 953 | [[package]] 954 | name = "windows-sys" 955 | version = "0.61.2" 956 | source = "registry+https://github.com/rust-lang/crates.io-index" 957 | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 958 | dependencies = [ 959 | "windows-link", 960 | ] 961 | 962 | [[package]] 963 | name = "windows-targets" 964 | version = "0.52.6" 965 | source = "registry+https://github.com/rust-lang/crates.io-index" 966 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 967 | dependencies = [ 968 | "windows_aarch64_gnullvm", 969 | "windows_aarch64_msvc", 970 | "windows_i686_gnu", 971 | "windows_i686_gnullvm", 972 | "windows_i686_msvc", 973 | "windows_x86_64_gnu", 974 | "windows_x86_64_gnullvm", 975 | "windows_x86_64_msvc", 976 | ] 977 | 978 | [[package]] 979 | name = "windows_aarch64_gnullvm" 980 | version = "0.52.6" 981 | source = "registry+https://github.com/rust-lang/crates.io-index" 982 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 983 | 984 | [[package]] 985 | name = "windows_aarch64_msvc" 986 | version = "0.52.6" 987 | source = "registry+https://github.com/rust-lang/crates.io-index" 988 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 989 | 990 | [[package]] 991 | name = "windows_i686_gnu" 992 | version = "0.52.6" 993 | source = "registry+https://github.com/rust-lang/crates.io-index" 994 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 995 | 996 | [[package]] 997 | name = "windows_i686_gnullvm" 998 | version = "0.52.6" 999 | source = "registry+https://github.com/rust-lang/crates.io-index" 1000 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1001 | 1002 | [[package]] 1003 | name = "windows_i686_msvc" 1004 | version = "0.52.6" 1005 | source = "registry+https://github.com/rust-lang/crates.io-index" 1006 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1007 | 1008 | [[package]] 1009 | name = "windows_x86_64_gnu" 1010 | version = "0.52.6" 1011 | source = "registry+https://github.com/rust-lang/crates.io-index" 1012 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1013 | 1014 | [[package]] 1015 | name = "windows_x86_64_gnullvm" 1016 | version = "0.52.6" 1017 | source = "registry+https://github.com/rust-lang/crates.io-index" 1018 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1019 | 1020 | [[package]] 1021 | name = "windows_x86_64_msvc" 1022 | version = "0.52.6" 1023 | source = "registry+https://github.com/rust-lang/crates.io-index" 1024 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1025 | 1026 | [[package]] 1027 | name = "wit-bindgen" 1028 | version = "0.46.0" 1029 | source = "registry+https://github.com/rust-lang/crates.io-index" 1030 | checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" 1031 | 1032 | [[package]] 1033 | name = "zeroize" 1034 | version = "1.8.2" 1035 | source = "registry+https://github.com/rust-lang/crates.io-index" 1036 | checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 1037 | --------------------------------------------------------------------------------