Explore the YouTubeTranscripts-macOS project to understand what's needed for TestFlight deployment:

1. Check if there's an Xcode project (.xcodeproj) or just Swift Package
2. Look at the current build configuration (Package.swift, Info.plist, entitlements)
3. Check for App Store/TestFlight requirements:
   - Bundle identifier
   - Signing configuration
   - App icons
   - Privacy descriptions (if any)
4. Identify any external dependencies that need to be bundled:
   - Python/Whisper - how is this handled?
   - FFmpeg
   - Any other binaries
5. Check if there are any hardcoded paths that would break on other devices

Project path: /Users/neog/Apps/macOS/YouTubeTranscripts-macOS

Provide a detailed report on the current state and what needs to be configured for TestFlight.


I'll explore the YouTubeTranscripts-macOS project to understand its current state and TestFlight deployment requirements.


Bash

List contents of YouTubeTranscripts-macOS directory


find "/Users/neog/Apps/macOS/YouTubeTranscripts-macOS" -maxdepth 3 -type f \( -name "*.xcodeproj" -o -name "*.pbxproj" -o -name "Package.swift" -o -name "Info.plist" -o -name "*.entitlements" \) 2>/dev/null

Find Xcode project and configuration files


total 80
drwxr-xr-x@ 14 neog  staff   448 Dec 21 16:06 .
drwxr-xr-x@ 27 neog  staff   864 Dec 26 14:55 ..
-rw-r--r--@  1 neog  staff  6148 Dec 14 11:10 .DS_Store
drwxr-xr-x@ 13 neog  staff   416 Dec 26 19:53 .build
drwxr-xr-x@ 12 neog  staff   384 Dec 26 17:30 .git
-rw-------@  1 neog  staff   712 Dec 21 14:44 .gitignore
-rw-------@  1 neog  staff  1068 Dec 21 15:56 LICENSE
-rw-------@  1 neog  staff   469 Dec  3 13:49 Package.swift
-rw-------@  1 neog  staff  4729 Dec 21 15:54 README.md
drwx------@  3 neog  staff    96 Dec  8 02:05 Scripts
drwxr-xr-x@ 11 neog  staff   352 Dec  3 16:22 Sources
-rw-------@  1 neog  staff  4928 Dec 21 14:02 WHISPER-SETUP.md
drwxr-xr-x@  6 neog  staff   192 Dec  8 02:45 YouTubeTranscripts.xcodeproj
-rw-------@  1 neog  staff   699 Dec  3 01:43 project.yml


Good! The project uses Xcode. Let me examine the key configuration files:


     1→// swift-tools-version: 5.9
     2→import PackageDescription
     3→
     4→let package = Package(
     5→    name: "YouTubeTranscripts",
     6→    platforms: [.macOS(.v14)],
     7→    products: [
     8→        .executable(name: "YouTubeTranscripts", targets: ["YouTubeTranscripts"])
     9→    ],
    10→    targets: [
    11→        .executableTarget(
    12→            name: "YouTubeTranscripts",
    13→            path: "Sources",
    14→            swiftSettings: [
    15→                .unsafeFlags(["-parse-as-library"])
    16→            ]
    17→        )
    18→    ]
    19→)
    20→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

// swift-tools-version: 5.9
import PackageDescription

let package = Package(
    name: "YouTubeTranscripts",
    platforms: [.macOS(.v14)],
    products: [
        .executable(name: "YouTubeTranscripts", targets: ["YouTubeTranscripts"])
    ],
    targets: [
        .executableTarget(
            name: "YouTubeTranscripts",
            path: "Sources",
            swiftSettings: [
                .unsafeFlags(["-parse-as-library"])
            ]
        )
    ]
)


     1→<?xml version="1.0" encoding="UTF-8"?>
     2→<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
     3→<plist version="1.0">
     4→<dict>
     5→    <key>CFBundleDevelopmentRegion</key>
     6→    <string>$(DEVELOPMENT_LANGUAGE)</string>
     7→    <key>CFBundleExecutable</key>
     8→    <string>$(EXECUTABLE_NAME)</string>
     9→    <key>CFBundleIconFile</key>
    10→    <string></string>
    11→    <key>CFBundleIdentifier</key>
    12→    <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
    13→    <key>CFBundleInfoDictionaryVersion</key>
    14→    <string>6.0</string>
    15→    <key>CFBundleName</key>
    16→    <string>$(PRODUCT_NAME)</string>
    17→    <key>CFBundlePackageType</key>
    18→    <string>APPL</string>
    19→    <key>CFBundleShortVersionString</key>
    20→    <string>1.0</string>
    21→    <key>CFBundleVersion</key>
    22→    <string>1</string>
    23→    <key>LSMinimumSystemVersion</key>
    24→    <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
    25→    <key>NSMainStoryboardFile</key>
    26→    <string></string>
    27→    <key>NSPrincipalClass</key>
    28→    <string>NSApplication</string>
    29→    <key>NSHumanReadableCopyright</key>
    30→    <string>Copyright 2024 NEOG. All rights reserved.</string>
    31→    <key>NSLocalNetworkUsageDescription</key>
    32→    <string>YouTube Transcripts needs local network access to connect to LM Studio for AI-powered transcript summaries.</string>
    33→    <key>NSBonjourServices</key>
    34→    <array>
    35→        <string>_http._tcp</string>
    36→    </array>
    37→</dict>
    38→</plist>
    39→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleDevelopmentRegion</key>
    <string>$(DEVELOPMENT_LANGUAGE)</string>
    <key>CFBundleExecutable</key>
    <string>$(EXECUTABLE_NAME)</string>
    <key>CFBundleIconFile</key>
    <string></string>
    <key>CFBundleIdentifier</key>
    <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
    <key>CFBundleInfoDictionaryVersion</key>
    <string>6.0</string>
    <key>CFBundleName</key>
    <string>$(PRODUCT_NAME)</string>
    <key>CFBundlePackageType</key>
    <string>APPL</string>
    <key>CFBundleShortVersionString</key>
    <string>1.0</string>
    <key>CFBundleVersion</key>
    <string>1</string>
    <key>LSMinimumSystemVersion</key>
    <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
    <key>NSMainStoryboardFile</key>
    <string></string>
    <key>NSPrincipalClass</key>
    <string>NSApplication</string>
    <key>NSHumanReadableCopyright</key>
    <string>Copyright 2024 NEOG. All rights reserved.</string>
    <key>NSLocalNetworkUsageDescription</key>
    <string>YouTube Transcripts needs local network access to connect to LM Studio for AI-powered transcript summaries.</string>
    <key>NSBonjourServices</key>
    <array>
        <string>_http._tcp</string>
    </array>
</dict>
</plist>


     1→<?xml version="1.0" encoding="UTF-8"?>
     2→<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
     3→<plist version="1.0">
     4→<dict/>
     5→</plist>
     6→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>


Now let me check the Xcode project structure and build settings:


ls -la "/Users/neog/Apps/macOS/YouTubeTranscripts-macOS/Sources/" 2>/dev/null

List sources directory


     1→// !$*UTF8*$!
     2→{
     3→	archiveVersion = 1;
     4→	classes = {
     5→	};
     6→	objectVersion = 77;
     7→	objects = {
     8→
     9→/* Begin PBXBuildFile section */
    10→		0F1FB89C0602AF08AAF755CF /* SearchOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05605160B4AEA3F1F86C45C9 /* SearchOptions.swift */; };
    11→		1E942D7DBF059407FDA00D67 /* TTSService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFBF5D4DE0241399CE1E5ED /* TTSService.swift */; };
    12→		29ABD8A78387C774FCF8D495 /* FormattingPrompts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22035FC0610E531CFC6997D0 /* FormattingPrompts.swift */; };
    13→		2B2F0C6554FF2542496AC3D7 /* YouTubeTranscriptsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452AE6F2F10DB7D6F477D5CD /* YouTubeTranscriptsApp.swift */; };
    14→		351C2C4B907ED40DCDE35550 /* ReadingProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27B12A3E1654442FC2A2732C /* ReadingProgressBar.swift */; };
    15→		3CD961102556C5B836A0AB54 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451B6E76FBE7381E2B6BBE71 /* ContentView.swift */; };
    16→		406A63254756718872098905 /* NotesPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4735F90BFF076076F09438 /* NotesPanel.swift */; };
    17→		448315C454FB839345949D49 /* WhisperService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E70ED7ACCC27177D6EFDB2 /* WhisperService.swift */; };
    18→		4DFAD078DCACF2235C353EA2 /* AudioDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CBED4A24EEBCDD40A7D439 /* AudioDownloadService.swift */; };
    19→		584D4744EE4D41066D198BB6 /* AIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C31A40EAB1180602BA012B /* AIService.swift */; };
    20→		5AEC1666D94D894EEF0AF7DD /* TranscriptReadingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FDDC07B0CA39FB096BF6A3 /* TranscriptReadingViewModel.swift */; };
    21→		60DC886CC85A95956F0A0D25 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621FA7903738029E2B36703F /* SettingsView.swift */; };
    22→		681C8ABB8CBF8AF55EE158F3 /* TTSHighlightedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE19ACF9C125FDA37DC3B484 /* TTSHighlightedText.swift */; };
    23→		683B7678FA8A04191F09C50A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 020A8FFE4D90EE28744B9E9A /* Assets.xcassets */; };
    24→		6EA4A61789179B7D6124344D /* AdvancedSearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C82F7D6B0DDF9AFDDDC35B6 /* AdvancedSearchBar.swift */; };
    25→		6FA30343ABC4EF27FEB8AE2D /* TranscriptReadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536B84D92E7005AA5A8C085A /* TranscriptReadingView.swift */; };
    26→		7B0D0046060F91785F9F096A /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD35A47019A8FADF9B699AF6 /* AppConfiguration.swift */; };
    27→		7D1531F354B55D4C1111A5A0 /* SavedVideo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FDAF30ED83ECBB6A349BBE3 /* SavedVideo.swift */; };
    28→		82FA735190C09346BF11AD6C /* TextFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465B307968E2371AA3D2EB68 /* TextFormatting.swift */; };
    29→		864D4764082EE5842E7177DF /* TypographyPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC53A4E0AE3EA79592CB541 /* TypographyPopover.swift */; };
    30→		9AE35C787133FA897E989FF8 /* TranscriptImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72D32E29B6E561B593ED28E8 /* TranscriptImporter.swift */; };
    31→		9B09DB49F8A688F9A5D5E2E0 /* MCPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB855F6872D9264812C977C /* MCPService.swift */; };
    32→		9D53946D7774BD025CCF9DAE /* SharedComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6423852F68E53646B4D0597A /* SharedComponents.swift */; };
    33→		B1DE0070B7E10B2440C9990B /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1765BFE8D769F4D1F720E3C9 /* AppLogger.swift */; };
    34→		CBA3509B0D6BE76980132484 /* TranscriptInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB5CBC8741145E56A29E957 /* TranscriptInputView.swift */; };
    35→		D305D1F3B576F58C82E3C14F /* AppCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC112C6D3737469F6AB4783 /* AppCommands.swift */; };
    36→		D428A24B03327C960A353113 /* TranscriptInputViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EE7555B0100DAE7AB2078D9 /* TranscriptInputViewModel.swift */; };
    37→		D55358342D5C9CA21B963D14 /* DiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6E27228015F4B2F54578B1 /* DiagnosticsView.swift */; };
    38→		DC682105ECB87687D87242CD /* TTSControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FCE73C546E708B9B45490A0 /* TTSControlsView.swift */; };
    39→		E2264ACD16AEFF0F8FA0FD6F /* Formatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DC1701BDB1587B21472318D /* Formatters.swift */; };
    40→		EA7086E6A34FDA7909E31AAC /* AnnotatedTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B49F9487FD451463D576CC47 /* AnnotatedTextView.swift */; };
    41→		EAC39FAA25540AACFCE87D3C /* FormatOptionsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEA3EED370E09C63F5A4CB99 /* FormatOptionsSheet.swift */; };
    42→		F9DC301DBE8276918EDBC0B6 /* SearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A1BE5433A25FFDE50CBFFA /* SearchService.swift */; };
    43→/* End PBXBuildFile section */
    44→
    45→/* Begin PBXFileReference section */
    46→		020A8FFE4D90EE28744B9E9A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
    47→		05605160B4AEA3F1F86C45C9 /* SearchOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOptions.swift; sourceTree = "<group>"; };
    48→		14C31A40EAB1180602BA012B /* AIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIService.swift; sourceTree = "<group>"; };
    49→		1765BFE8D769F4D1F720E3C9 /* AppLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogger.swift; sourceTree = "<group>"; };
    50→		1DC1701BDB1587B21472318D /* Formatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatters.swift; sourceTree = "<group>"; };
    51→		1FC53A4E0AE3EA79592CB541 /* TypographyPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypographyPopover.swift; sourceTree = "<group>"; };
    52→		1FCE73C546E708B9B45490A0 /* TTSControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTSControlsView.swift; sourceTree = "<group>"; };
    53→		22035FC0610E531CFC6997D0 /* FormattingPrompts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattingPrompts.swift; sourceTree = "<group>"; };
    54→		27B12A3E1654442FC2A2732C /* ReadingProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingProgressBar.swift; sourceTree = "<group>"; };
    55→		2CB855F6872D9264812C977C /* MCPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCPService.swift; sourceTree = "<group>"; };
    56→		2EC112C6D3737469F6AB4783 /* AppCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCommands.swift; sourceTree = "<group>"; };
    57→		31CBED4A24EEBCDD40A7D439 /* AudioDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDownloadService.swift; sourceTree = "<group>"; };
    58→		35A1BE5433A25FFDE50CBFFA /* SearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchService.swift; sourceTree = "<group>"; };
    59→		3EE7555B0100DAE7AB2078D9 /* TranscriptInputViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptInputViewModel.swift; sourceTree = "<group>"; };
    60→		451B6E76FBE7381E2B6BBE71 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
    61→		452AE6F2F10DB7D6F477D5CD /* YouTubeTranscriptsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeTranscriptsApp.swift; sourceTree = "<group>"; };
    62→		465B307968E2371AA3D2EB68 /* TextFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFormatting.swift; sourceTree = "<group>"; };
    63→		48BF65ECD6062F685214FD83 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
    64→		4C82F7D6B0DDF9AFDDDC35B6 /* AdvancedSearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSearchBar.swift; sourceTree = "<group>"; };
    65→		4F4735F90BFF076076F09438 /* NotesPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesPanel.swift; sourceTree = "<group>"; };
    66→		536B84D92E7005AA5A8C085A /* TranscriptReadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptReadingView.swift; sourceTree = "<group>"; };
    67→		5C6E27228015F4B2F54578B1 /* DiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsView.swift; sourceTree = "<group>"; };
    68→		621FA7903738029E2B36703F /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
    69→		6423852F68E53646B4D0597A /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = "<group>"; };
    70→		72D32E29B6E561B593ED28E8 /* TranscriptImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptImporter.swift; sourceTree = "<group>"; };
    71→		7EB5CBC8741145E56A29E957 /* TranscriptInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptInputView.swift; sourceTree = "<group>"; };
    72→		9FDAF30ED83ECBB6A349BBE3 /* SavedVideo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedVideo.swift; sourceTree = "<group>"; };
    73→		A1F86BBD7AF1A8CE81DABB68 /* YouTubeTranscripts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = YouTubeTranscripts.app; sourceTree = BUILT_PRODUCTS_DIR; };
    74→		AE19ACF9C125FDA37DC3B484 /* TTSHighlightedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTSHighlightedText.swift; sourceTree = "<group>"; };
    75→		B49F9487FD451463D576CC47 /* AnnotatedTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotatedTextView.swift; sourceTree = "<group>"; };
    76→		CD35A47019A8FADF9B699AF6 /* AppConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfiguration.swift; sourceTree = "<group>"; };
    77→		DEFBF5D4DE0241399CE1E5ED /* TTSService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTSService.swift; sourceTree = "<group>"; };
    78→		E4FDDC07B0CA39FB096BF6A3 /* TranscriptReadingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptReadingViewModel.swift; sourceTree = "<group>"; };
    79→		E9E70ED7ACCC27177D6EFDB2 /* WhisperService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhisperService.swift; sourceTree = "<group>"; };
    80→		EEA3EED370E09C63F5A4CB99 /* FormatOptionsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormatOptionsSheet.swift; sourceTree = "<group>"; };
    81→		F86C1A972C6A0895CB02B46C /* YouTubeTranscripts.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = YouTubeTranscripts.entitlements; sourceTree = "<group>"; };
    82→/* End PBXFileReference section */
    83→
    84→/* Begin PBXGroup section */
    85→		15E0CA3D3BC787BF7A7F5EB6 /* Core */ = {
    86→			isa = PBXGroup;
    87→			children = (
    88→				2EC112C6D3737469F6AB4783 /* AppCommands.swift */,
    89→				CD35A47019A8FADF9B699AF6 /* AppConfiguration.swift */,
    90→				1DC1701BDB1587B21472318D /* Formatters.swift */,
    91→				22035FC0610E531CFC6997D0 /* FormattingPrompts.swift */,
    92→				465B307968E2371AA3D2EB68 /* TextFormatting.swift */,
    93→				72D32E29B6E561B593ED28E8 /* TranscriptImporter.swift */,
    94→			);
    95→			path = Core;
    96→			sourceTree = "<group>";
    97→		};
    98→		2F16972E8EBBB9123F921CD5 /* Sources */ = {
    99→			isa = PBXGroup;
   100→			children = (
   101→				020A8FFE4D90EE28744B9E9A /* Assets.xcassets */,
   102→				48BF65ECD6062F685214FD83 /* Info.plist */,
   103→				F86C1A972C6A0895CB02B46C /* YouTubeTranscripts.entitlements */,
   104→				A4624EE9EB557AF03AF75221 /* App */,
   105→				15E0CA3D3BC787BF7A7F5EB6 /* Core */,
   106→				8FD799F4859DCB5020C0DA42 /* Models */,
   107→				AB15905C5A0BAB5EB8563A82 /* Services */,
   108→				64D54CB0C2309F8D400813B3 /* ViewModels */,
   109→				93DF104737BE20357E0286D1 /* Views */,
   110→			);
   111→			path = Sources;
   112→			sourceTree = "<group>";
   113→		};
   114→		64D54CB0C2309F8D400813B3 /* ViewModels */ = {
   115→			isa = PBXGroup;
   116→			children = (
   117→				3EE7555B0100DAE7AB2078D9 /* TranscriptInputViewModel.swift */,
   118→				E4FDDC07B0CA39FB096BF6A3 /* TranscriptReadingViewModel.swift */,
   119→			);
   120→			path = ViewModels;
   121→			sourceTree = "<group>";
   122→		};
   123→		685147A77DCE320464D2360E = {
   124→			isa = PBXGroup;
   125→			children = (
   126→				2F16972E8EBBB9123F921CD5 /* Sources */,
   127→				FA9A87C68B7E33850BAB4E31 /* Products */,
   128→			);
   129→			sourceTree = "<group>";
   130→		};
   131→		8FD799F4859DCB5020C0DA42 /* Models */ = {
   132→			isa = PBXGroup;
   133→			children = (
   134→				9FDAF30ED83ECBB6A349BBE3 /* SavedVideo.swift */,
   135→				05605160B4AEA3F1F86C45C9 /* SearchOptions.swift */,
   136→			);
   137→			path = Models;
   138→			sourceTree = "<group>";
   139→		};
   140→		93DF104737BE20357E0286D1 /* Views */ = {
   141→			isa = PBXGroup;
   142→			children = (
   143→				451B6E76FBE7381E2B6BBE71 /* ContentView.swift */,
   144→				5C6E27228015F4B2F54578B1 /* DiagnosticsView.swift */,
   145→				4F4735F90BFF076076F09438 /* NotesPanel.swift */,
   146→				621FA7903738029E2B36703F /* SettingsView.swift */,
   147→				7EB5CBC8741145E56A29E957 /* TranscriptInputView.swift */,
   148→				536B84D92E7005AA5A8C085A /* TranscriptReadingView.swift */,
   149→				C5FFD53198A47DB76F234B47 /* Components */,
   150→			);
   151→			path = Views;
   152→			sourceTree = "<group>";
   153→		};
   154→		A4624EE9EB557AF03AF75221 /* App */ = {
   155→			isa = PBXGroup;
   156→			children = (
   157→				452AE6F2F10DB7D6F477D5CD /* YouTubeTranscriptsApp.swift */,
   158→			);
   159→			path = App;
   160→			sourceTree = "<group>";
   161→		};
   162→		AB15905C5A0BAB5EB8563A82 /* Services */ = {
   163→			isa = PBXGroup;
   164→			children = (
   165→				14C31A40EAB1180602BA012B /* AIService.swift */,
   166→				1765BFE8D769F4D1F720E3C9 /* AppLogger.swift */,
   167→				31CBED4A24EEBCDD40A7D439 /* AudioDownloadService.swift */,
   168→				2CB855F6872D9264812C977C /* MCPService.swift */,
   169→				35A1BE5433A25FFDE50CBFFA /* SearchService.swift */,
   170→				DEFBF5D4DE0241399CE1E5ED /* TTSService.swift */,
   171→				E9E70ED7ACCC27177D6EFDB2 /* WhisperService.swift */,
   172→			);
   173→			path = Services;
   174→			sourceTree = "<group>";
   175→		};
   176→		C5FFD53198A47DB76F234B47 /* Components */ = {
   177→			isa = PBXGroup;
   178→			children = (
   179→				4C82F7D6B0DDF9AFDDDC35B6 /* AdvancedSearchBar.swift */,
   180→				B49F9487FD451463D576CC47 /* AnnotatedTextView.swift */,
   181→				EEA3EED370E09C63F5A4CB99 /* FormatOptionsSheet.swift */,
   182→				27B12A3E1654442FC2A2732C /* ReadingProgressBar.swift */,
   183→				6423852F68E53646B4D0597A /* SharedComponents.swift */,
   184→				1FCE73C546E708B9B45490A0 /* TTSControlsView.swift */,
   185→				AE19ACF9C125FDA37DC3B484 /* TTSHighlightedText.swift */,
   186→				1FC53A4E0AE3EA79592CB541 /* TypographyPopover.swift */,
   187→			);
   188→			path = Components;
   189→			sourceTree = "<group>";
   190→		};
   191→		FA9A87C68B7E33850BAB4E31 /* Products */ = {
   192→			isa = PBXGroup;
   193→			children = (
   194→				A1F86BBD7AF1A8CE81DABB68 /* YouTubeTranscripts.app */,
   195→			);
   196→			name = Products;
   197→			sourceTree = "<group>";
   198→		};
   199→/* End PBXGroup section */
   200→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

// !$*UTF8*$!
{
	archiveVersion = 1;
	classes = {
	};
	objectVersion = 77;
	objects = {

/* Begin PBXBuildFile section */
		0F1FB89C0602AF08AAF755CF /* SearchOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05605160B4AEA3F1F86C45C9 /* SearchOptions.swift */; };
		1E942D7DBF059407FDA00D67 /* TTSService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFBF5D4DE0241399CE1E5ED /* TTSService.swift */; };
		29ABD8A78387C774FCF8D495 /* FormattingPrompts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22035FC0610E531CFC6997D0 /* FormattingPrompts.swift */; };
		2B2F0C6554FF2542496AC3D7 /* YouTubeTranscriptsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452AE6F2F10DB7D6F477D5CD /* YouTubeTranscriptsApp.swift */; };
		351C2C4B907ED40DCDE35550 /* ReadingProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27B12A3E1654442FC2A2732C /* ReadingProgressBar.swift */; };
		3CD961102556C5B836A0AB54 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451B6E76FBE7381E2B6BBE71 /* ContentView.swift */; };
		406A63254756718872098905 /* NotesPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4735F90BFF076076F09438 /* NotesPanel.swift */; };
		448315C454FB839345949D49 /* WhisperService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E70ED7ACCC27177D6EFDB2 /* WhisperService.swift */; };
		4DFAD078DCACF2235C353EA2 /* AudioDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CBED4A24EEBCDD40A7D439 /* AudioDownloadService.swift */; };
		584D4744EE4D41066D198BB6 /* AIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C31A40EAB1180602BA012B /* AIService.swift */; };
		5AEC1666D94D894EEF0AF7DD /* TranscriptReadingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FDDC07B0CA39FB096BF6A3 /* TranscriptReadingViewModel.swift */; };
		60DC886CC85A95956F0A0D25 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621FA7903738029E2B36703F /* SettingsView.swift */; };
		681C8ABB8CBF8AF55EE158F3 /* TTSHighlightedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE19ACF9C125FDA37DC3B484 /* TTSHighlightedText.swift */; };
		683B7678FA8A04191F09C50A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 020A8FFE4D90EE28744B9E9A /* Assets.xcassets */; };
		6EA4A61789179B7D6124344D /* AdvancedSearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C82F7D6B0DDF9AFDDDC35B6 /* AdvancedSearchBar.swift */; };
		6FA30343ABC4EF27FEB8AE2D /* TranscriptReadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536B84D92E7005AA5A8C085A /* TranscriptReadingView.swift */; };
		7B0D0046060F91785F9F096A /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD35A47019A8FADF9B699AF6 /* AppConfiguration.swift */; };
		7D1531F354B55D4C1111A5A0 /* SavedVideo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FDAF30ED83ECBB6A349BBE3 /* SavedVideo.swift */; };
		82FA735190C09346BF11AD6C /* TextFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465B307968E2371AA3D2EB68 /* TextFormatting.swift */; };
		864D4764082EE5842E7177DF /* TypographyPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC53A4E0AE3EA79592CB541 /* TypographyPopover.swift */; };
		9AE35C787133FA897E989FF8 /* TranscriptImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72D32E29B6E561B593ED28E8 /* TranscriptImporter.swift */; };
		9B09DB49F8A688F9A5D5E2E0 /* MCPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB855F6872D9264812C977C /* MCPService.swift */; };
		9D53946D7774BD025CCF9DAE /* SharedComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6423852F68E53646B4D0597A /* SharedComponents.swift */; };
		B1DE0070B7E10B2440C9990B /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1765BFE8D769F4D1F720E3C9 /* AppLogger.swift */; };
		CBA3509B0D6BE76980132484 /* TranscriptInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB5CBC8741145E56A29E957 /* TranscriptInputView.swift */; };
		D305D1F3B576F58C82E3C14F /* AppCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC112C6D3737469F6AB4783 /* AppCommands.swift */; };
		D428A24B03327C960A353113 /* TranscriptInputViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EE7555B0100DAE7AB2078D9 /* TranscriptInputViewModel.swift */; };
		D55358342D5C9CA21B963D14 /* DiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6E27228015F4B2F54578B1 /* DiagnosticsView.swift */; };
		DC682105ECB87687D87242CD /* TTSControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FCE73C546E708B9B45490A0 /* TTSControlsView.swift */; };
		E2264ACD16AEFF0F8FA0FD6F /* Formatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DC1701BDB1587B21472318D /* Formatters.swift */; };
		EA7086E6A34FDA7909E31AAC /* AnnotatedTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B49F9487FD451463D576CC47 /* AnnotatedTextView.swift */; };
		EAC39FAA25540AACFCE87D3C /* FormatOptionsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEA3EED370E09C63F5A4CB99 /* FormatOptionsSheet.swift */; };
		F9DC301DBE8276918EDBC0B6 /* SearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A1BE5433A25FFDE50CBFFA /* SearchService.swift */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
		020A8FFE4D90EE28744B9E9A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
		05605160B4AEA3F1F86C45C9 /* SearchOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOptions.swift; sourceTree = "<group>"; };
		14C31A40EAB1180602BA012B /* AIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIService.swift; sourceTree = "<group>"; };
		1765BFE8D769F4D1F720E3C9 /* AppLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogger.swift; sourceTree = "<group>"; };
		1DC1701BDB1587B21472318D /* Formatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatters.swift; sourceTree = "<group>"; };
		1FC53A4E0AE3EA79592CB541 /* TypographyPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypographyPopover.swift; sourceTree = "<group>"; };
		1FCE73C546E708B9B45490A0 /* TTSControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTSControlsView.swift; sourceTree = "<group>"; };
		22035FC0610E531CFC6997D0 /* FormattingPrompts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattingPrompts.swift; sourceTree = "<group>"; };
		27B12A3E1654442FC2A2732C /* ReadingProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingProgressBar.swift; sourceTree = "<group>"; };
		2CB855F6872D9264812C977C /* MCPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCPService.swift; sourceTree = "<group>"; };
		2EC112C6D3737469F6AB4783 /* AppCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCommands.swift; sourceTree = "<group>"; };
		31CBED4A24EEBCDD40A7D439 /* AudioDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDownloadService.swift; sourceTree = "<group>"; };
		35A1BE5433A25FFDE50CBFFA /* SearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchService.swift; sourceTree = "<group>"; };
		3EE7555B0100DAE7AB2078D9 /* TranscriptInputViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptInputViewModel.swift; sourceTree = "<group>"; };
		451B6E76FBE7381E2B6BBE71 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
		452AE6F2F10DB7D6F477D5CD /* YouTubeTranscriptsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeTranscriptsApp.swift; sourceTree = "<group>"; };
		465B307968E2371AA3D2EB68 /* TextFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFormatting.swift; sourceTree = "<group>"; };
		48BF65ECD6062F685214FD83 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
		4C82F7D6B0DDF9AFDDDC35B6 /* AdvancedSearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSearchBar.swift; sourceTree = "<group>"; };
		4F4735F90BFF076076F09438 /* NotesPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesPanel.swift; sourceTree = "<group>"; };
		536B84D92E7005AA5A8C085A /* TranscriptReadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptReadingView.swift; sourceTree = "<group>"; };
		5C6E27228015F4B2F54578B1 /* DiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsView.swift; sourceTree = "<group>"; };
		621FA7903738029E2B36703F /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
		6423852F68E53646B4D0597A /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = "<group>"; };
		72D32E29B6E561B593ED28E8 /* TranscriptImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptImporter.swift; sourceTree = "<group>"; };
		7EB5CBC8741145E56A29E957 /* TranscriptInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptInputView.swift; sourceTree = "<group>"; };
		9FDAF30ED83ECBB6A349BBE3 /* SavedVideo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedVideo.swift; sourceTree = "<group>"; };
		A1F86BBD7AF1A8CE81DABB68 /* YouTubeTranscripts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = YouTubeTranscripts.app; sourceTree = BUILT_PRODUCTS_DIR; };
		AE19ACF9C125FDA37DC3B484 /* TTSHighlightedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTSHighlightedText.swift; sourceTree = "<group>"; };
		B49F9487FD451463D576CC47 /* AnnotatedTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotatedTextView.swift; sourceTree = "<group>"; };
		CD35A47019A8FADF9B699AF6 /* AppConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfiguration.swift; sourceTree = "<group>"; };
		DEFBF5D4DE0241399CE1E5ED /* TTSService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTSService.swift; sourceTree = "<group>"; };
		E4FDDC07B0CA39FB096BF6A3 /* TranscriptReadingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptReadingViewModel.swift; sourceTree = "<group>"; };
		E9E70ED7ACCC27177D6EFDB2 /* WhisperService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhisperService.swift; sourceTree = "<group>"; };
		EEA3EED370E09C63F5A4CB99 /* FormatOptionsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormatOptionsSheet.swift; sourceTree = "<group>"; };
		F86C1A972C6A0895CB02B46C /* YouTubeTranscripts.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = YouTubeTranscripts.entitlements; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXGroup section */
		15E0CA3D3BC787BF7A7F5EB6 /* Core */ = {
			isa = PBXGroup;
			children = (
				2EC112C6D3737469F6AB4783 /* AppCommands.swift */,
				CD35A47019A8FADF9B699AF6 /* AppConfiguration.swift */,
				1DC1701BDB1587B21472318D /* Formatters.swift */,
				22035FC0610E531CFC6997D0 /* FormattingPrompts.swift */,
				465B307968E2371AA3D2EB68 /* TextFormatting.swift */,
				72D32E29B6E561B593ED28E8 /* TranscriptImporter.swift */,
			);
			path = Core;
			sourceTree = "<group>";
		};
		2F16972E8EBBB9123F921CD5 /* Sources */ = {
			isa = PBXGroup;
			children = (
				020A8FFE4D90EE28744B9E9A /* Assets.xcassets */,
				48BF65ECD6062F685214FD83 /* Info.plist */,
				F86C1A972C6A0895CB02B46C /* YouTubeTranscripts.entitlements */,
				A4624EE9EB557AF03AF75221 /* App */,
				15E0CA3D3BC787BF7A7F5EB6 /* Core */,
				8FD799F4859DCB5020C0DA42 /* Models */,
				AB15905C5A0BAB5EB8563A82 /* Services */,
				64D54CB0C2309F8D400813B3 /* ViewModels */,
				93DF104737BE20357E0286D1 /* Views */,
			);
			path = Sources;
			sourceTree = "<group>";
		};
		64D54CB0C2309F8D400813B3 /* ViewModels */ = {
			isa = PBXGroup;
			children = (
				3EE7555B0100DAE7AB2078D9 /* TranscriptInputViewModel.swift */,
				E4FDDC07B0CA39FB096BF6A3 /* TranscriptReadingViewModel.swift */,
			);
			path = ViewModels;
			sourceTree = "<group>";
		};
		685147A77DCE320464D2360E = {
			isa = PBXGroup;
			children = (
				2F16972E8EBBB9123F921CD5 /* Sources */,
				FA9A87C68B7E33850BAB4E31 /* Products */,
			);
			sourceTree = "<group>";
		};
		8FD799F4859DCB5020C0DA42 /* Models */ = {
			isa = PBXGroup;
			children = (
				9FDAF30ED83ECBB6A349BBE3 /* SavedVideo.swift */,
				05605160B4AEA3F1F86C45C9 /* SearchOptions.swift */,
			);
			path = Models;
			sourceTree = "<group>";
		};
		93DF104737BE20357E0286D1 /* Views */ = {
			isa = PBXGroup;
			children = (
				451B6E76FBE7381E2B6BBE71 /* ContentView.swift */,
				5C6E27228015F4B2F54578B1 /* DiagnosticsView.swift */,
				4F4735F90BFF076076F09438 /* NotesPanel.swift */,
				621FA7903738029E2B36703F /* SettingsView.swift */,
				7EB5CBC8741145E56A29E957 /* TranscriptInputView.swift */,
				536B84D92E7005AA5A8C085A /* TranscriptReadingView.swift */,
				C5FFD53198A47DB76F234B47 /* Components */,
			);
			path = Views;
			sourceTree = "<group>";
		};
		A4624EE9EB557AF03AF75221 /* App */ = {
			isa = PBXGroup;
			children = (
				452AE6F2F10DB7D6F477D5CD /* YouTubeTranscriptsApp.swift */,
			);
			path = App;
			sourceTree = "<group>";
		};
		AB15905C5A0BAB5EB8563A82 /* Services */ = {
			isa = PBXGroup;
			children = (
				14C31A40EAB1180602BA012B /* AIService.swift */,
				1765BFE8D769F4D1F720E3C9 /* AppLogger.swift */,
				31CBED4A24EEBCDD40A7D439 /* AudioDownloadService.swift */,
				2CB855F6872D9264812C977C /* MCPService.swift */,
				35A1BE5433A25FFDE50CBFFA /* SearchService.swift */,
				DEFBF5D4DE0241399CE1E5ED /* TTSService.swift */,
				E9E70ED7ACCC27177D6EFDB2 /* WhisperService.swift */,
			);
			path = Services;
			sourceTree = "<group>";
		};
		C5FFD53198A47DB76F234B47 /* Components */ = {
			isa = PBXGroup;
			children = (
				4C82F7D6B0DDF9AFDDDC35B6 /* AdvancedSearchBar.swift */,
				B49F9487FD451463D576CC47 /* AnnotatedTextView.swift */,
				EEA3EED370E09C63F5A4CB99 /* FormatOptionsSheet.swift */,
				27B12A3E1654442FC2A2732C /* ReadingProgressBar.swift */,
				6423852F68E53646B4D0597A /* SharedComponents.swift */,
				1FCE73C546E708B9B45490A0 /* TTSControlsView.swift */,
				AE19ACF9C125FDA37DC3B484 /* TTSHighlightedText.swift */,
				1FC53A4E0AE3EA79592CB541 /* TypographyPopover.swift */,
			);
			path = Components;
			sourceTree = "<group>";
		};
		FA9A87C68B7E33850BAB4E31 /* Products */ = {
			isa = PBXGroup;
			children = (
				A1F86BBD7AF1A8CE81DABB68 /* YouTubeTranscripts.app */,
			);
			name = Products;
			sourceTree = "<group>";
		};
/* End PBXGroup section */


total 16
drwxr-xr-x@ 11 neog  staff   352 Dec  3 16:22 .
drwxr-xr-x@ 14 neog  staff   448 Dec 21 16:06 ..
drwxr-xr-x@  3 neog  staff    96 Dec 26 17:20 App
drwxr-xr-x@  5 neog  staff   160 Dec  3 02:21 Assets.xcassets
drwxr-xr-x@  9 neog  staff   288 Dec 26 19:52 Core
-rw-------@  1 neog  staff  1387 Dec  3 03:31 Info.plist
drwxr-xr-x@  5 neog  staff   160 Dec 26 17:19 Models
drwxr-xr-x@ 10 neog  staff   320 Dec 26 19:52 Services
drwx------@  5 neog  staff   160 Dec 26 18:00 ViewModels
drwxr-xr-x@ 12 neog  staff   384 Dec 26 19:34 Views
-rw-------   1 neog  staff   181 Dec  3 16:22 YouTubeTranscripts.entitlements


Let me check the source files to understand dependencies and hardcoded paths:


find "/Users/neog/Apps/macOS/YouTubeTranscripts-macOS/Sources" -name "*.swift" -type f | head -20

Find Swift source files


     1→# YouTube Transcripts for macOS
     2→
     3→A native macOS app for fetching, managing, and reading YouTube video transcripts with AI-powered features.
     4→
     5→![macOS](https://img.shields.io/badge/macOS-14.0+-blue)
     6→![Swift](https://img.shields.io/badge/Swift-5.9+-orange)
     7→![License](https://img.shields.io/badge/license-MIT-green)
     8→
     9→## Features
    10→
    11→- **Fetch Transcripts** - Get transcripts from any YouTube video
    12→- **Multi-language Support** - Auto-detects and fetches transcripts in PT, EN, ES, FR
    13→- **Whisper Fallback** - Automatically transcribes audio when captions aren't available
    14→- **AI Summaries** - Generate summaries using local LM Studio
    15→- **AI Formatting** - Clean up and format transcripts with AI
    16→- **Text-to-Speech** - Listen to transcripts with highlighted text
    17→- **Annotations** - Highlight and underline important passages
    18→- **Notes** - Add personal notes to each transcript
    19→- **Export** - Save as Markdown or plain text
    20→- **Favorites** - Organize your transcript library
    21→
    22→## Screenshots
    23→
    24→| Library | Reading | New Transcript |
    25→|---------|---------|----------------|
    26→| Browse saved transcripts | Read with TTS | Fetch new videos |
    27→
    28→## Installation
    29→
    30→### Prerequisites
    31→
    32→1. **macOS 14.0+** (Sonoma or later)
    33→2. **Xcode 15+**
    34→3. **Python 3.9+**
    35→4. **ffmpeg** (for audio conversion)
    36→
    37→### Setup
    38→
    39→1. Clone the repository:
    40→```bash
    41→git clone https://github.com/leandro1416/YouTubeTranscripts-macOS.git
    42→cd YouTubeTranscripts-macOS
    43→```
    44→
    45→2. Install Python dependencies:
    46→```bash
    47→pip3 install pytubefix        # Primary audio downloader
    48→pip3 install yt-dlp           # Fallback downloader
    49→pip3 install youtube_transcript_api  # Caption fetcher
    50→pip3 install openai-whisper   # Local transcription
    51→```
    52→
    53→3. Install ffmpeg:
    54→```bash
    55→brew install ffmpeg
    56→```
    57→
    58→4. Open in Xcode and build:
    59→```bash
    60→open YouTubeTranscripts.xcodeproj
    61→```
    62→
    63→5. Press `Cmd+R` to build and run.
    64→
    65→## Usage
    66→
    67→### Fetching a Transcript
    68→
    69→1. Click **Add** or press `Cmd+N`
    70→2. Paste a YouTube URL
    71→3. Select language (optional)
    72→4. Click **Get Transcript**
    73→
    74→### Transcript Sources
    75→
    76→The app tries multiple methods to get transcripts:
    77→
    78→| Priority | Method | When Used |
    79→|----------|--------|-----------|
    80→| 1 | YouTube Captions | Video has subtitles |
    81→| 2 | pytubefix + Whisper | No captions available |
    82→| 3 | yt-dlp + Whisper | pytubefix fails |
    83→
    84→### AI Features (Optional)
    85→
    86→To enable AI features, run [LM Studio](https://lmstudio.ai) locally:
    87→
    88→1. Download and install LM Studio
    89→2. Load a model (e.g., Llama, Mistral)
    90→3. Start the local server on port 1234
    91→4. AI features will automatically connect
    92→
    93→## Architecture
    94→
    95→```
    96→YouTubeTranscripts-macOS/
    97→├── Sources/
    98→│   ├── App/                 # App entry point
    99→│   ├── Core/                # Configuration, formatters
   100→│   ├── Models/              # SwiftData models
   101→│   ├── Services/            # Business logic
   102→│   │   ├── MCPService       # YouTube caption fetcher
   103→│   │   ├── WhisperService   # Local transcription
   104→│   │   ├── AudioDownloadService  # pytubefix/yt-dlp
   105→│   │   ├── AIService        # LM Studio integration
   106→│   │   └── TTSService       # Text-to-speech
   107→│   ├── ViewModels/          # MVVM view models
   108→│   └── Views/               # SwiftUI views
   109→└── WHISPER-SETUP.md         # Detailed setup guide
   110→```
   111→
   112→## Technologies
   113→
   114→- **SwiftUI** - Native macOS UI
   115→- **SwiftData** - Local persistence
   116→- **AVFoundation** - Text-to-speech
   117→- **Python** - Backend scripts (Whisper, yt-dlp)
   118→- **OpenAI Whisper** - Speech-to-text
   119→- **pytubefix** - YouTube audio download
   120→
   121→## Troubleshooting
   122→
   123→### "No transcript available"
   124→
   125→The video might not have captions. The app will automatically try to:
   126→1. Download the audio
   127→2. Transcribe with Whisper
   128→
   129→This takes longer but works for any video.
   130→
   131→### "Download failed (403 Forbidden)"
   132→
   133→YouTube occasionally blocks downloads. Solutions:
   134→- Wait a few minutes and try again
   135→- Make sure `pytubefix` is up to date: `pip3 install -U pytubefix`
   136→- Log into YouTube in your browser (cookies help)
   137→
   138→### "Whisper not found"
   139→
   140→Install Whisper:
   141→```bash
   142→pip3 install openai-whisper
   143→```
   144→
   145→### "ffmpeg not found"
   146→
   147→Install ffmpeg:
   148→```bash
   149→brew install ffmpeg
   150→```
   151→
   152→## Contributing
   153→
   154→Contributions are welcome! Please:
   155→
   156→1. Fork the repository
   157→2. Create a feature branch
   158→3. Make your changes
   159→4. Submit a pull request
   160→
   161→## License
   162→
   163→MIT License - see [LICENSE](LICENSE) for details.
   164→
   165→## Acknowledgments
   166→
   167→- [OpenAI Whisper](https://github.com/openai/whisper) - Speech recognition
   168→- [pytubefix](https://github.com/JuanBindez/pytubefix) - YouTube downloads
   169→- [yt-dlp](https://github.com/yt-dlp/yt-dlp) - Video/audio extraction
   170→- [youtube-transcript-api](https://github.com/jdepoix/youtube-transcript-api) - Caption fetching
   171→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

# YouTube Transcripts for macOS

A native macOS app for fetching, managing, and reading YouTube video transcripts with AI-powered features.

![macOS](https://img.shields.io/badge/macOS-14.0+-blue)
![Swift](https://img.shields.io/badge/Swift-5.9+-orange)
![License](https://img.shields.io/badge/license-MIT-green)

## Features

- **Fetch Transcripts** - Get transcripts from any YouTube video
- **Multi-language Support** - Auto-detects and fetches transcripts in PT, EN, ES, FR
- **Whisper Fallback** - Automatically transcribes audio when captions aren't available
- **AI Summaries** - Generate summaries using local LM Studio
- **AI Formatting** - Clean up and format transcripts with AI
- **Text-to-Speech** - Listen to transcripts with highlighted text
- **Annotations** - Highlight and underline important passages
- **Notes** - Add personal notes to each transcript
- **Export** - Save as Markdown or plain text
- **Favorites** - Organize your transcript library

## Screenshots

| Library | Reading | New Transcript |
|---------|---------|----------------|
| Browse saved transcripts | Read with TTS | Fetch new videos |

## Installation

### Prerequisites

1. **macOS 14.0+** (Sonoma or later)
2. **Xcode 15+**
3. **Python 3.9+**
4. **ffmpeg** (for audio conversion)

### Setup

1. Clone the repository:
```bash
git clone https://github.com/leandro1416/YouTubeTranscripts-macOS.git
cd YouTubeTranscripts-macOS
```

2. Install Python dependencies:
```bash
pip3 install pytubefix        # Primary audio downloader
pip3 install yt-dlp           # Fallback downloader
pip3 install youtube_transcript_api  # Caption fetcher
pip3 install openai-whisper   # Local transcription
```

3. Install ffmpeg:
```bash
brew install ffmpeg
```

4. Open in Xcode and build:
```bash
open YouTubeTranscripts.xcodeproj
```

5. Press `Cmd+R` to build and run.

## Usage

### Fetching a Transcript

1. Click **Add** or press `Cmd+N`
2. Paste a YouTube URL
3. Select language (optional)
4. Click **Get Transcript**

### Transcript Sources

The app tries multiple methods to get transcripts:

| Priority | Method | When Used |
|----------|--------|-----------|
| 1 | YouTube Captions | Video has subtitles |
| 2 | pytubefix + Whisper | No captions available |
| 3 | yt-dlp + Whisper | pytubefix fails |

### AI Features (Optional)

To enable AI features, run [LM Studio](https://lmstudio.ai) locally:

1. Download and install LM Studio
2. Load a model (e.g., Llama, Mistral)
3. Start the local server on port 1234
4. AI features will automatically connect

## Architecture

```
YouTubeTranscripts-macOS/
├── Sources/
│   ├── App/                 # App entry point
│   ├── Core/                # Configuration, formatters
│   ├── Models/              # SwiftData models
│   ├── Services/            # Business logic
│   │   ├── MCPService       # YouTube caption fetcher
│   │   ├── WhisperService   # Local transcription
│   │   ├── AudioDownloadService  # pytubefix/yt-dlp
│   │   ├── AIService        # LM Studio integration
│   │   └── TTSService       # Text-to-speech
│   ├── ViewModels/          # MVVM view models
│   └── Views/               # SwiftUI views
└── WHISPER-SETUP.md         # Detailed setup guide
```

## Technologies

- **SwiftUI** - Native macOS UI
- **SwiftData** - Local persistence
- **AVFoundation** - Text-to-speech
- **Python** - Backend scripts (Whisper, yt-dlp)
- **OpenAI Whisper** - Speech-to-text
- **pytubefix** - YouTube audio download

## Troubleshooting

### "No transcript available"

The video might not have captions. The app will automatically try to:
1. Download the audio
2. Transcribe with Whisper

This takes longer but works for any video.

### "Download failed (403 Forbidden)"

YouTube occasionally blocks downloads. Solutions:
- Wait a few minutes and try again
- Make sure `pytubefix` is up to date: `pip3 install -U pytubefix`
- Log into YouTube in your browser (cookies help)

### "Whisper not found"

Install Whisper:
```bash
pip3 install openai-whisper
```

### "ffmpeg not found"

Install ffmpeg:
```bash
brew install ffmpeg
```

## Contributing

Contributions are welcome! Please:

1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Submit a pull request

## License

MIT License - see [LICENSE](LICENSE) for details.

## Acknowledgments

- [OpenAI Whisper](https://github.com/openai/whisper) - Speech recognition
- [pytubefix](https://github.com/JuanBindez/pytubefix) - YouTube downloads
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) - Video/audio extraction
- [youtube-transcript-api](https://github.com/jdepoix/youtube-transcript-api) - Caption fetching


     1→# Whisper Transcription Setup Guide
     2→
     3→## What is Whisper?
     4→
     5→Whisper is OpenAI's open-source speech-to-text model that can transcribe audio from YouTube videos when no captions are available. It's **completely FREE** and runs **100% locally** on your Mac.
     6→
     7→## Why Use Whisper?
     8→
     9→- ✅ **$0 cost** - completely free, no API keys needed
    10→- ✅ **No YouTube captions required** - transcribe ANY video
    11→- ✅ **99+ languages** supported (Portuguese, English, Spanish, etc.)
    12→- ✅ **100% private** - audio never leaves your Mac
    13→- ✅ **Professional quality** - same model used by ChatGPT
    14→
    15→## Installation (One-Time Setup)
    16→
    17→### Step 1: Install Homebrew (if not installed)
    18→
    19→```bash
    20→# Check if Homebrew is installed
    21→which brew
    22→
    23→# If not installed, run:
    24→/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
    25→```
    26→
    27→### Step 2: Install ffmpeg
    28→
    29→```bash
    30→brew install ffmpeg
    31→```
    32→
    33→### Step 3: Install Python dependencies
    34→
    35→```bash
    36→# Install pytubefix (primary audio downloader - most reliable)
    37→pip3 install pytubefix
    38→
    39→# Install yt-dlp (fallback audio downloader)
    40→pip3 install yt-dlp
    41→
    42→# Install Whisper (transcription)
    43→pip3 install openai-whisper
    44→```
    45→
    46→### Step 4: Verify installations
    47→
    48→```bash
    49→# Check pytubefix (primary downloader)
    50→python3 -c "import pytubefix; print('pytubefix installed:', pytubefix.__version__)"
    51→
    52→# Check yt-dlp (fallback)
    53→yt-dlp --version
    54→
    55→# Check ffmpeg
    56→ffmpeg -version
    57→
    58→# Check Whisper
    59→python3 -c "import whisper; print('Whisper installed:', whisper.__version__)"
    60→```
    61→
    62→**Expected output:**
    63→```
    64→✅ pytubefix installed: X.X.X
    65→✅ yt-dlp version: 2025.xx.xx
    66→✅ ffmpeg version X.X.X
    67→✅ Whisper installed: X.X.X
    68→```
    69→
    70→## First-Time Model Download
    71→
    72→When you use Whisper for the first time, it will automatically download the model (~140MB for base model). This only happens once.
    73→
    74→```bash
    75→# Optional: Pre-download the base model
    76→whisper --model base --language English /dev/null 2>/dev/null || true
    77→```
    78→
    79→## How It Works in the App
    80→
    81→1. **Try YouTube captions first** (instant, if available)
    82→2. **If no captions found:**
    83→   - Download audio from video (MP3, ~1-2 min for 10 min video)
    84→   - Transcribe with Whisper (~3-5 min for 10 min video)
    85→   - Save transcript to database
    86→
    87→## Processing Times
    88→
    89→For a **10-minute video** with Whisper Base model:
    90→- Audio download: ~1-2 minutes
    91→- Transcription: ~3-5 minutes
    92→- **Total: ~4-7 minutes**
    93→
    94→For a **60-minute video**:
    95→- Audio download: ~3-5 minutes
    96→- Transcription: ~20-30 minutes
    97→- **Total: ~23-35 minutes**
    98→
    99→## Whisper Model Sizes
   100→
   101→| Model | RAM | Speed | Quality | Best For |
   102→|-------|-----|-------|---------|----------|
   103→| Tiny | ~1GB | Fastest | Good | Quick drafts |
   104→| **Base** | **~1GB** | **Fast** | **Great** | **Recommended** |
   105→| Small | ~2GB | Medium | Excellent | High quality |
   106→| Medium | ~5GB | Slow | Excellent | Professional |
   107→| Large | ~10GB | Slowest | Best | Maximum accuracy |
   108→
   109→**App default:** Base model (best balance of speed/quality)
   110→
   111→## Troubleshooting
   112→
   113→### Error: "pytubefix not found" or "No module named 'pytubefix'"
   114→```bash
   115→pip3 install pytubefix
   116→```
   117→
   118→### Error: "yt-dlp not found"
   119→```bash
   120→pip3 install yt-dlp
   121→```
   122→
   123→### Error: "ffmpeg not found"
   124→```bash
   125→brew install ffmpeg
   126→```
   127→
   128→### Error: "No module named 'whisper'"
   129→```bash
   130→pip3 install openai-whisper
   131→```
   132→
   133→### Error: "Out of memory" during transcription
   134→Solution: The base model requires ~1GB RAM. If you're having issues, close other applications or use the tiny model (see Settings).
   135→
   136→### Video download fails
   137→Possible causes:
   138→- Private/deleted video
   139→- Geo-restricted content
   140→- Network issues
   141→
   142→Try again or check video accessibility in your browser.
   143→
   144→## Advanced: Custom Model Size
   145→
   146→In the future, we'll add a Settings option to choose model size. For now, the app uses the Base model by default.
   147→
   148→## Privacy & Security
   149→
   150→- ✅ **All processing happens locally** on your Mac
   151→- ✅ **No data sent to OpenAI** or any cloud service
   152→- ✅ **Audio files are temporary** and deleted after transcription
   153→- ✅ **No API keys required**
   154→
   155→## Cost Comparison
   156→
   157→| Method | Cost | Speed | Privacy |
   158→|--------|------|-------|---------|
   159→| **Whisper Local** | **$0** | 3-5x video duration | 100% Private |
   160→| Whisper API | $0.006/min | 1-2 min fixed | Sent to OpenAI |
   161→| Human Transcription | $1-3/min | 24+ hours | Varies |
   162→
   163→## Need Help?
   164→
   165→If you encounter issues:
   166→
   167→1. Check that all dependencies are installed:
   168→   ```bash
   169→   yt-dlp --version
   170→   ffmpeg -version
   171→   python3 -c "import whisper"
   172→   ```
   173→
   174→2. Try with a short video first (< 5 minutes)
   175→
   176→3. Check the app logs in Diagnostics for detailed error messages
   177→
   178→4. Report issues with:
   179→   - macOS version
   180→   - Error message
   181→   - Video URL (if applicable)
   182→
   183→## Resources
   184→
   185→- **Whisper GitHub:** https://github.com/openai/whisper
   186→- **yt-dlp GitHub:** https://github.com/yt-dlp/yt-dlp
   187→- **ffmpeg Website:** https://ffmpeg.org/
   188→
   189→---
   190→
   191→**You're ready!** The app will now automatically transcribe videos without captions using Whisper. 🎉
   192→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

# Whisper Transcription Setup Guide

## What is Whisper?

Whisper is OpenAI's open-source speech-to-text model that can transcribe audio from YouTube videos when no captions are available. It's **completely FREE** and runs **100% locally** on your Mac.

## Why Use Whisper?

- ✅ **$0 cost** - completely free, no API keys needed
- ✅ **No YouTube captions required** - transcribe ANY video
- ✅ **99+ languages** supported (Portuguese, English, Spanish, etc.)
- ✅ **100% private** - audio never leaves your Mac
- ✅ **Professional quality** - same model used by ChatGPT

## Installation (One-Time Setup)

### Step 1: Install Homebrew (if not installed)

```bash
# Check if Homebrew is installed
which brew

# If not installed, run:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```

### Step 2: Install ffmpeg

```bash
brew install ffmpeg
```

### Step 3: Install Python dependencies

```bash
# Install pytubefix (primary audio downloader - most reliable)
pip3 install pytubefix

# Install yt-dlp (fallback audio downloader)
pip3 install yt-dlp

# Install Whisper (transcription)
pip3 install openai-whisper
```

### Step 4: Verify installations

```bash
# Check pytubefix (primary downloader)
python3 -c "import pytubefix; print('pytubefix installed:', pytubefix.__version__)"

# Check yt-dlp (fallback)
yt-dlp --version

# Check ffmpeg
ffmpeg -version

# Check Whisper
python3 -c "import whisper; print('Whisper installed:', whisper.__version__)"
```

**Expected output:**
```
✅ pytubefix installed: X.X.X
✅ yt-dlp version: 2025.xx.xx
✅ ffmpeg version X.X.X
✅ Whisper installed: X.X.X
```

## First-Time Model Download

When you use Whisper for the first time, it will automatically download the model (~140MB for base model). This only happens once.

```bash
# Optional: Pre-download the base model
whisper --model base --language English /dev/null 2>/dev/null || true
```

## How It Works in the App

1. **Try YouTube captions first** (instant, if available)
2. **If no captions found:**
   - Download audio from video (MP3, ~1-2 min for 10 min video)
   - Transcribe with Whisper (~3-5 min for 10 min video)
   - Save transcript to database

## Processing Times

For a **10-minute video** with Whisper Base model:
- Audio download: ~1-2 minutes
- Transcription: ~3-5 minutes
- **Total: ~4-7 minutes**

For a **60-minute video**:
- Audio download: ~3-5 minutes
- Transcription: ~20-30 minutes
- **Total: ~23-35 minutes**

## Whisper Model Sizes

| Model | RAM | Speed | Quality | Best For |
|-------|-----|-------|---------|----------|
| Tiny | ~1GB | Fastest | Good | Quick drafts |
| **Base** | **~1GB** | **Fast** | **Great** | **Recommended** |
| Small | ~2GB | Medium | Excellent | High quality |
| Medium | ~5GB | Slow | Excellent | Professional |
| Large | ~10GB | Slowest | Best | Maximum accuracy |

**App default:** Base model (best balance of speed/quality)

## Troubleshooting

### Error: "pytubefix not found" or "No module named 'pytubefix'"
```bash
pip3 install pytubefix
```

### Error: "yt-dlp not found"
```bash
pip3 install yt-dlp
```

### Error: "ffmpeg not found"
```bash
brew install ffmpeg
```

### Error: "No module named 'whisper'"
```bash
pip3 install openai-whisper
```

### Error: "Out of memory" during transcription
Solution: The base model requires ~1GB RAM. If you're having issues, close other applications or use the tiny model (see Settings).

### Video download fails
Possible causes:
- Private/deleted video
- Geo-restricted content
- Network issues

Try again or check video accessibility in your browser.

## Advanced: Custom Model Size

In the future, we'll add a Settings option to choose model size. For now, the app uses the Base model by default.

## Privacy & Security

- ✅ **All processing happens locally** on your Mac
- ✅ **No data sent to OpenAI** or any cloud service
- ✅ **Audio files are temporary** and deleted after transcription
- ✅ **No API keys required**

## Cost Comparison

| Method | Cost | Speed | Privacy |
|--------|------|-------|---------|
| **Whisper Local** | **$0** | 3-5x video duration | 100% Private |
| Whisper API | $0.006/min | 1-2 min fixed | Sent to OpenAI |
| Human Transcription | $1-3/min | 24+ hours | Varies |

## Need Help?

If you encounter issues:

1. Check that all dependencies are installed:
   ```bash
   yt-dlp --version
   ffmpeg -version
   python3 -c "import whisper"
   ```

2. Try with a short video first (< 5 minutes)

3. Check the app logs in Diagnostics for detailed error messages

4. Report issues with:
   - macOS version
   - Error message
   - Video URL (if applicable)

## Resources

- **Whisper GitHub:** https://github.com/openai/whisper
- **yt-dlp GitHub:** https://github.com/yt-dlp/yt-dlp
- **ffmpeg Website:** https://ffmpeg.org/

---

**You're ready!** The app will now automatically transcribe videos without captions using Whisper. 🎉


Let me check the key service files for hardcoded paths and external dependencies:


     1→import Foundation
     2→
     3→/// Service for transcribing audio files using OpenAI Whisper (local)
     4→actor WhisperService {
     5→    static let shared = WhisperService()
     6→
     7→    private init() {}
     8→
     9→    /// Whisper model sizes with different accuracy/speed trade-offs
    10→    enum ModelSize: String, Codable {
    11→        case tiny      // ~1GB RAM, fastest, less accurate
    12→        case base      // ~1GB RAM, fast, decent accuracy
    13→        case small     // ~2GB RAM, balanced
    14→        case medium    // ~5GB RAM, good accuracy
    15→        case large     // ~10GB RAM, best accuracy
    16→
    17→        var description: String {
    18→            switch self {
    19→            case .tiny: return "Tiny (rápido, menos preciso)"
    20→            case .base: return "Base (balanceado)"
    21→            case .small: return "Small (preciso, mais lento)"
    22→            case .medium: return "Medium (muito preciso)"
    23→            case .large: return "Large (melhor qualidade)"
    24→            }
    25→        }
    26→    }
    27→
    28→    /// Transcribes audio file using Whisper
    29→    /// - Parameters:
    30→    ///   - audioPath: Path to audio file (MP3, WAV, etc.)
    31→    ///   - language: Optional language hint (e.g. "portuguese", "english")
    32→    ///   - modelSize: Whisper model size to use
    33→    ///   - progress: Progress callback with status messages
    34→    /// - Returns: Transcribed text
    35→    func transcribe(
    36→        audioPath: URL,
    37→        language: String? = nil,
    38→        modelSize: ModelSize = .base,
    39→        progress: @escaping @Sendable (String) -> Void
    40→    ) async throws -> String {
    41→        await MainActor.run { progress("🤖 Transcrevendo áudio com Whisper (\(modelSize.rawValue))...") }
    42→        await AppLogger.shared.info("Starting Whisper transcription", category: .transcript, metadata: [
    43→            "audio_path": audioPath.path,
    44→            "model": modelSize.rawValue,
    45→            "language": language ?? "auto"
    46→        ])
    47→
    48→        let script = """
    49→        import sys
    50→        sys.stdout.reconfigure(line_buffering=True)
    51→        import whisper
    52→        import os
    53→
    54→        # Add ffmpeg to PATH for Whisper
    55→        os.environ["PATH"] = "\(AppConfig.Executables.ffmpegDirectory):" + os.environ.get("PATH", "")
    56→
    57→        audio_path = '\(audioPath.path.replacingOccurrences(of: "'", with: "\\'"))'
    58→        model_size = '\(modelSize.rawValue)'
    59→        language = \(language != nil ? "'\(language!)'" : "None")
    60→
    61→        try:
    62→            print("PROGRESS: Carregando modelo Whisper...", flush=True)
    63→            model = whisper.load_model(model_size)
    64→
    65→            print("PROGRESS: Transcrevendo áudio (pode demorar alguns minutos)...", flush=True)
    66→
    67→            # Transcribe with optional language hint
    68→            options = {}
    69→            if language:
    70→                options["language"] = language
    71→
    72→            result = model.transcribe(audio_path, **options, verbose=False)
    73→
    74→            print("PROGRESS: Transcrição concluída!", flush=True)
    75→            print("TRANSCRIPT_START")
    76→            print(result["text"])
    77→            print("TRANSCRIPT_END")
    78→
    79→            # Print detected language
    80→            if "language" in result:
    81→                print(f"DETECTED_LANGUAGE: {result['language']}")
    82→        except Exception as e:
    83→            import traceback
    84→            print(f"ERROR: {str(e)}")
    85→            print(traceback.format_exc(), file=sys.stderr)
    86→        """
    87→
    88→        let output = try await runPythonWithProgress(
    89→            script: script,
    90→            timeout: 3600, // 1 hour for long videos
    91→            progress: progress
    92→        )
    93→
    94→        if output.contains("ERROR:") {
    95→            let errorMsg = output.replacingOccurrences(of: "ERROR: ", with: "")
    96→            await AppLogger.shared.error("Whisper transcription failed: \(errorMsg)", category: .transcript)
    97→            throw WhisperError.transcriptionFailed(errorMsg)
    98→        }
    99→
   100→        // Extract transcript
   101→        if let startRange = output.range(of: "TRANSCRIPT_START\n"),
   102→           let endRange = output.range(of: "\nTRANSCRIPT_END") {
   103→            let transcript = String(output[startRange.upperBound..<endRange.lowerBound])
   104→            let result = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
   105→
   106→            // Extract detected language
   107→            var detectedLang: String?
   108→            if let langMatch = output.range(of: "DETECTED_LANGUAGE: ") {
   109→                let remaining = String(output[langMatch.upperBound...])
   110→                if let lineEnd = remaining.firstIndex(of: "\n") {
   111→                    detectedLang = String(remaining[..<lineEnd])
   112→                }
   113→            }
   114→
   115→            await AppLogger.shared.info("Transcription completed successfully", category: .transcript, metadata: [
   116→                "word_count": String(result.split(separator: " ").count),
   117→                "char_count": String(result.count),
   118→                "detected_language": detectedLang ?? "unknown"
   119→            ])
   120→
   121→            return result
   122→        }
   123→
   124→        throw WhisperError.invalidResponse
   125→    }
   126→
   127→    /// Transcribes with timestamps (returns formatted text with [MM:SS] timestamps)
   128→    /// - Parameters:
   129→    ///   - audioPath: Path to audio file
   130→    ///   - language: Optional language hint
   131→    ///   - modelSize: Whisper model size to use
   132→    ///   - progress: Progress callback
   133→    /// - Returns: Transcribed text with timestamps
   134→    func transcribeWithTimestamps(
   135→        audioPath: URL,
   136→        language: String? = nil,
   137→        modelSize: ModelSize = .base,
   138→        progress: @escaping @Sendable (String) -> Void
   139→    ) async throws -> String {
   140→        await MainActor.run { progress("🤖 Transcrevendo com timestamps...") }
   141→
   142→        let script = """
   143→        import sys
   144→        sys.stdout.reconfigure(line_buffering=True)
   145→        import whisper
   146→        import os
   147→
   148→        # Add ffmpeg to PATH for Whisper
   149→        os.environ["PATH"] = "\(AppConfig.Executables.ffmpegDirectory):" + os.environ.get("PATH", "")
   150→
   151→        audio_path = '\(audioPath.path.replacingOccurrences(of: "'", with: "\\'"))'
   152→        model_size = '\(modelSize.rawValue)'
   153→        language = \(language != nil ? "'\(language!)'" : "None")
   154→
   155→        def format_time(seconds):
   156→            mins = int(seconds // 60)
   157→            secs = int(seconds % 60)
   158→            return f"[{mins:02d}:{secs:02d}]"
   159→
   160→        try:
   161→            print("PROGRESS: Carregando modelo...", flush=True)
   162→            model = whisper.load_model(model_size)
   163→
   164→            print("PROGRESS: Transcrevendo com timestamps...", flush=True)
   165→            options = {}
   166→            if language:
   167→                options["language"] = language
   168→
   169→            result = model.transcribe(audio_path, **options, verbose=False)
   170→
   171→            print("TRANSCRIPT_START")
   172→            for segment in result["segments"]:
   173→                timestamp = format_time(segment["start"])
   174→                text = segment["text"].strip()
   175→                print(f"{timestamp} {text}")
   176→            print("TRANSCRIPT_END")
   177→        except Exception as e:
   178→            import traceback
   179→            print(f"ERROR: {str(e)}")
   180→            print(traceback.format_exc(), file=sys.stderr)
   181→        """
   182→
   183→        let output = try await runPythonWithProgress(
   184→            script: script,
   185→            timeout: 3600,
   186→            progress: progress
   187→        )
   188→
   189→        if output.contains("ERROR:") {
   190→            let errorMsg = output.replacingOccurrences(of: "ERROR: ", with: "")
   191→            throw WhisperError.transcriptionFailed(errorMsg)
   192→        }
   193→
   194→        if let startRange = output.range(of: "TRANSCRIPT_START\n"),
   195→           let endRange = output.range(of: "\nTRANSCRIPT_END") {
   196→            let transcript = String(output[startRange.upperBound..<endRange.lowerBound])
   197→            return transcript.trimmingCharacters(in: .whitespacesAndNewlines)
   198→        }
   199→
   200→        throw WhisperError.invalidResponse
   201→    }
   202→
   203→    // MARK: - Process Data Buffer
   204→
   205→    /// Actor-based buffer for thread-safe process output collection
   206→    private actor ProcessDataBuffer {
   207→        private var data = Data()
   208→        private var isCompleted = false
   209→
   210→        func append(_ newData: Data) {
   211→            data.append(newData)
   212→        }
   213→
   214→        func getData() -> Data {
   215→            return data
   216→        }
   217→
   218→        func markCompleted() -> Bool {
   219→            if isCompleted {
   220→                return true  // Already completed
   221→            }
   222→            isCompleted = true
   223→            return false  // First to complete
   224→        }
   225→
   226→        func checkCompleted() -> Bool {
   227→            return isCompleted
   228→        }
   229→    }
   230→
   231→    /// Helper method to run Python script with progress feedback
   232→    private func runPythonWithProgress(
   233→        script: String,
   234→        timeout: TimeInterval,
   235→        progress: @escaping @Sendable (String) -> Void
   236→    ) async throws -> String {
   237→        return try await withCheckedThrowingContinuation { continuation in
   238→            let process = Process()
   239→            // Use arch -arm64 to ensure native ARM execution for PyTorch/Whisper
   240→            process.executableURL = URL(fileURLWithPath: "/usr/bin/arch")
   241→            process.arguments = ["-arm64", AppConfig.Executables.pythonPath, "-c", script]
   242→
   243→            let outputPipe = Pipe()
   244→            let errorPipe = Pipe()
   245→            process.standardOutput = outputPipe
   246→            process.standardError = errorPipe
   247→
   248→            // ✅ Actor-isolated buffer (thread-safe)
   249→            let buffer = ProcessDataBuffer()
   250→
   251→            // Read output in real-time
   252→            outputPipe.fileHandleForReading.readabilityHandler = { handle in
   253→                let data = handle.availableData
   254→                if !data.isEmpty {
   255→                    Task {
   256→                        await buffer.append(data)
   257→                    }
   258→
   259→                    if let text = String(data: data, encoding: .utf8) {
   260→                        let lines = text.components(separatedBy: "\n")
   261→                        for line in lines {
   262→                            if line.hasPrefix("PROGRESS: ") {
   263→                                let message = String(line.dropFirst(10))
   264→                                Task { @MainActor in
   265→                                    progress("🔄 \(message)")
   266→                                }
   267→                            }
   268→                        }
   269→                    }
   270→                }
   271→            }
   272→
   273→            // Timeout timer
   274→            let timeoutTimer = DispatchSource.makeTimerSource(queue: .global())
   275→            timeoutTimer.schedule(deadline: .now() + timeout)
   276→            timeoutTimer.setEventHandler {
   277→                Task {
   278→                    let alreadyCompleted = await buffer.checkCompleted()
   279→                    if !alreadyCompleted {
   280→                        _ = await buffer.markCompleted()
   281→                        process.terminate()
   282→                        Task { @MainActor in
   283→                            progress("⚠ Timeout - transcrição demorou mais de \(Int(timeout/60)) minutos")
   284→                        }
   285→                        continuation.resume(throwing: WhisperError.timeout)
   286→                    }
   287→                }
   288→            }
   289→            timeoutTimer.resume()
   290→
   291→            process.terminationHandler = { proc in
   292→                timeoutTimer.cancel()
   293→                outputPipe.fileHandleForReading.readabilityHandler = nil
   294→
   295→                Task {
   296→                    let alreadyCompleted = await buffer.markCompleted()
   297→                    if alreadyCompleted {
   298→                        return  // Timeout already handled
   299→                    }
   300→
   301→                    // Read remaining data
   302→                    let remainingData = outputPipe.fileHandleForReading.readDataToEndOfFile()
   303→                    await buffer.append(remainingData)
   304→
   305→                    let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
   306→
   307→                    if proc.terminationStatus != 0 {
   308→                        let errorString = String(data: errorData, encoding: .utf8) ?? "Unknown error"
   309→                        continuation.resume(throwing: WhisperError.processError(errorString))
   310→                    } else {
   311→                        let outputData = await buffer.getData()
   312→                        guard let output = String(data: outputData, encoding: .utf8) else {
   313→                            continuation.resume(throwing: WhisperError.invalidResponse)
   314→                            return
   315→                        }
   316→                        continuation.resume(returning: output)
   317→                    }
   318→                }
   319→            }
   320→
   321→            do {
   322→                try process.run()
   323→            } catch {
   324→                timeoutTimer.cancel()
   325→                continuation.resume(throwing: WhisperError.processError(error.localizedDescription))
   326→            }
   327→        }
   328→    }
   329→}
   330→
   331→// MARK: - Errors
   332→
   333→enum WhisperError: LocalizedError {
   334→    case transcriptionFailed(String)
   335→    case invalidResponse
   336→    case modelNotAvailable
   337→    case timeout
   338→    case processError(String)
   339→
   340→    var errorDescription: String? {
   341→        switch self {
   342→        case .transcriptionFailed(let message):
   343→            return "Falha na transcrição: \(message)"
   344→        case .invalidResponse:
   345→            return "Resposta inválida do Whisper"
   346→        case .modelNotAvailable:
   347→            return "Modelo Whisper não disponível. Execute: pip3 install openai-whisper"
   348→        case .timeout:
   349→            return "Timeout na transcrição. Vídeo pode ser muito longo."
   350→        case .processError(let message):
   351→            return "Erro no processo Whisper: \(message)"
   352→        }
   353→    }
   354→}
   355→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

import Foundation

/// Service for transcribing audio files using OpenAI Whisper (local)
actor WhisperService {
    static let shared = WhisperService()

    private init() {}

    /// Whisper model sizes with different accuracy/speed trade-offs
    enum ModelSize: String, Codable {
        case tiny      // ~1GB RAM, fastest, less accurate
        case base      // ~1GB RAM, fast, decent accuracy
        case small     // ~2GB RAM, balanced
        case medium    // ~5GB RAM, good accuracy
        case large     // ~10GB RAM, best accuracy

        var description: String {
            switch self {
            case .tiny: return "Tiny (rápido, menos preciso)"
            case .base: return "Base (balanceado)"
            case .small: return "Small (preciso, mais lento)"
            case .medium: return "Medium (muito preciso)"
            case .large: return "Large (melhor qualidade)"
            }
        }
    }

    /// Transcribes audio file using Whisper
    /// - Parameters:
    ///   - audioPath: Path to audio file (MP3, WAV, etc.)
    ///   - language: Optional language hint (e.g. "portuguese", "english")
    ///   - modelSize: Whisper model size to use
    ///   - progress: Progress callback with status messages
    /// - Returns: Transcribed text
    func transcribe(
        audioPath: URL,
        language: String? = nil,
        modelSize: ModelSize = .base,
        progress: @escaping @Sendable (String) -> Void
    ) async throws -> String {
        await MainActor.run { progress("🤖 Transcrevendo áudio com Whisper (\(modelSize.rawValue))...") }
        await AppLogger.shared.info("Starting Whisper transcription", category: .transcript, metadata: [
            "audio_path": audioPath.path,
            "model": modelSize.rawValue,
            "language": language ?? "auto"
        ])

        let script = """
        import sys
        sys.stdout.reconfigure(line_buffering=True)
        import whisper
        import os

        # Add ffmpeg to PATH for Whisper
        os.environ["PATH"] = "\(AppConfig.Executables.ffmpegDirectory):" + os.environ.get("PATH", "")

        audio_path = '\(audioPath.path.replacingOccurrences(of: "'", with: "\\'"))'
        model_size = '\(modelSize.rawValue)'
        language = \(language != nil ? "'\(language!)'" : "None")

        try:
            print("PROGRESS: Carregando modelo Whisper...", flush=True)
            model = whisper.load_model(model_size)

            print("PROGRESS: Transcrevendo áudio (pode demorar alguns minutos)...", flush=True)

            # Transcribe with optional language hint
            options = {}
            if language:
                options["language"] = language

            result = model.transcribe(audio_path, **options, verbose=False)

            print("PROGRESS: Transcrição concluída!", flush=True)
            print("TRANSCRIPT_START")
            print(result["text"])
            print("TRANSCRIPT_END")

            # Print detected language
            if "language" in result:
                print(f"DETECTED_LANGUAGE: {result['language']}")
        except Exception as e:
            import traceback
            print(f"ERROR: {str(e)}")
            print(traceback.format_exc(), file=sys.stderr)
        """

        let output = try await runPythonWithProgress(
            script: script,
            timeout: 3600, // 1 hour for long videos
            progress: progress
        )

        if output.contains("ERROR:") {
            let errorMsg = output.replacingOccurrences(of: "ERROR: ", with: "")
            await AppLogger.shared.error("Whisper transcription failed: \(errorMsg)", category: .transcript)
            throw WhisperError.transcriptionFailed(errorMsg)
        }

        // Extract transcript
        if let startRange = output.range(of: "TRANSCRIPT_START\n"),
           let endRange = output.range(of: "\nTRANSCRIPT_END") {
            let transcript = String(output[startRange.upperBound..<endRange.lowerBound])
            let result = transcript.trimmingCharacters(in: .whitespacesAndNewlines)

            // Extract detected language
            var detectedLang: String?
            if let langMatch = output.range(of: "DETECTED_LANGUAGE: ") {
                let remaining = String(output[langMatch.upperBound...])
                if let lineEnd = remaining.firstIndex(of: "\n") {
                    detectedLang = String(remaining[..<lineEnd])
                }
            }

            await AppLogger.shared.info("Transcription completed successfully", category: .transcript, metadata: [
                "word_count": String(result.split(separator: " ").count),
                "char_count": String(result.count),
                "detected_language": detectedLang ?? "unknown"
            ])

            return result
        }

        throw WhisperError.invalidResponse
    }

    /// Transcribes with timestamps (returns formatted text with [MM:SS] timestamps)
    /// - Parameters:
    ///   - audioPath: Path to audio file
    ///   - language: Optional language hint
    ///   - modelSize: Whisper model size to use
    ///   - progress: Progress callback
    /// - Returns: Transcribed text with timestamps
    func transcribeWithTimestamps(
        audioPath: URL,
        language: String? = nil,
        modelSize: ModelSize = .base,
        progress: @escaping @Sendable (String) -> Void
    ) async throws -> String {
        await MainActor.run { progress("🤖 Transcrevendo com timestamps...") }

        let script = """
        import sys
        sys.stdout.reconfigure(line_buffering=True)
        import whisper
        import os

        # Add ffmpeg to PATH for Whisper
        os.environ["PATH"] = "\(AppConfig.Executables.ffmpegDirectory):" + os.environ.get("PATH", "")

        audio_path = '\(audioPath.path.replacingOccurrences(of: "'", with: "\\'"))'
        model_size = '\(modelSize.rawValue)'
        language = \(language != nil ? "'\(language!)'" : "None")

        def format_time(seconds):
            mins = int(seconds // 60)
            secs = int(seconds % 60)
            return f"[{mins:02d}:{secs:02d}]"

        try:
            print("PROGRESS: Carregando modelo...", flush=True)
            model = whisper.load_model(model_size)

            print("PROGRESS: Transcrevendo com timestamps...", flush=True)
            options = {}
            if language:
                options["language"] = language

            result = model.transcribe(audio_path, **options, verbose=False)

            print("TRANSCRIPT_START")
            for segment in result["segments"]:
                timestamp = format_time(segment["start"])
                text = segment["text"].strip()
                print(f"{timestamp} {text}")
            print("TRANSCRIPT_END")
        except Exception as e:
            import traceback
            print(f"ERROR: {str(e)}")
            print(traceback.format_exc(), file=sys.stderr)
        """

        let output = try await runPythonWithProgress(
            script: script,
            timeout: 3600,
            progress: progress
        )

        if output.contains("ERROR:") {
            let errorMsg = output.replacingOccurrences(of: "ERROR: ", with: "")
            throw WhisperError.transcriptionFailed(errorMsg)
        }

        if let startRange = output.range(of: "TRANSCRIPT_START\n"),
           let endRange = output.range(of: "\nTRANSCRIPT_END") {
            let transcript = String(output[startRange.upperBound..<endRange.lowerBound])
            return transcript.trimmingCharacters(in: .whitespacesAndNewlines)
        }

        throw WhisperError.invalidResponse
    }

    // MARK: - Process Data Buffer

    /// Actor-based buffer for thread-safe process output collection
    private actor ProcessDataBuffer {
        private var data = Data()
        private var isCompleted = false

        func append(_ newData: Data) {
            data.append(newData)
        }

        func getData() -> Data {
            return data
        }

        func markCompleted() -> Bool {
            if isCompleted {
                return true  // Already completed
            }
            isCompleted = true
            return false  // First to complete
        }

        func checkCompleted() -> Bool {
            return isCompleted
        }
    }

    /// Helper method to run Python script with progress feedback
    private func runPythonWithProgress(
        script: String,
        timeout: TimeInterval,
        progress: @escaping @Sendable (String) -> Void
    ) async throws -> String {
        return try await withCheckedThrowingContinuation { continuation in
            let process = Process()
            // Use arch -arm64 to ensure native ARM execution for PyTorch/Whisper
            process.executableURL = URL(fileURLWithPath: "/usr/bin/arch")
            process.arguments = ["-arm64", AppConfig.Executables.pythonPath, "-c", script]

            let outputPipe = Pipe()
            let errorPipe = Pipe()
            process.standardOutput = outputPipe
            process.standardError = errorPipe

            // ✅ Actor-isolated buffer (thread-safe)
            let buffer = ProcessDataBuffer()

            // Read output in real-time
            outputPipe.fileHandleForReading.readabilityHandler = { handle in
                let data = handle.availableData
                if !data.isEmpty {
                    Task {
                        await buffer.append(data)
                    }

                    if let text = String(data: data, encoding: .utf8) {
                        let lines = text.components(separatedBy: "\n")
                        for line in lines {
                            if line.hasPrefix("PROGRESS: ") {
                                let message = String(line.dropFirst(10))
                                Task { @MainActor in
                                    progress("🔄 \(message)")
                                }
                            }
                        }
                    }
                }
            }

            // Timeout timer
            let timeoutTimer = DispatchSource.makeTimerSource(queue: .global())
            timeoutTimer.schedule(deadline: .now() + timeout)
            timeoutTimer.setEventHandler {
                Task {
                    let alreadyCompleted = await buffer.checkCompleted()
                    if !alreadyCompleted {
                        _ = await buffer.markCompleted()
                        process.terminate()
                        Task { @MainActor in
                            progress("⚠ Timeout - transcrição demorou mais de \(Int(timeout/60)) minutos")
                        }
                        continuation.resume(throwing: WhisperError.timeout)
                    }
                }
            }
            timeoutTimer.resume()

            process.terminationHandler = { proc in
                timeoutTimer.cancel()
                outputPipe.fileHandleForReading.readabilityHandler = nil

                Task {
                    let alreadyCompleted = await buffer.markCompleted()
                    if alreadyCompleted {
                        return  // Timeout already handled
                    }

                    // Read remaining data
                    let remainingData = outputPipe.fileHandleForReading.readDataToEndOfFile()
                    await buffer.append(remainingData)

                    let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()

                    if proc.terminationStatus != 0 {
                        let errorString = String(data: errorData, encoding: .utf8) ?? "Unknown error"
                        continuation.resume(throwing: WhisperError.processError(errorString))
                    } else {
                        let outputData = await buffer.getData()
                        guard let output = String(data: outputData, encoding: .utf8) else {
                            continuation.resume(throwing: WhisperError.invalidResponse)
                            return
                        }
                        continuation.resume(returning: output)
                    }
                }
            }

            do {
                try process.run()
            } catch {
                timeoutTimer.cancel()
                continuation.resume(throwing: WhisperError.processError(error.localizedDescription))
            }
        }
    }
}

// MARK: - Errors

enum WhisperError: LocalizedError {
    case transcriptionFailed(String)
    case invalidResponse
    case modelNotAvailable
    case timeout
    case processError(String)

    var errorDescription: String? {
        switch self {
        case .transcriptionFailed(let message):
            return "Falha na transcrição: \(message)"
        case .invalidResponse:
            return "Resposta inválida do Whisper"
        case .modelNotAvailable:
            return "Modelo Whisper não disponível. Execute: pip3 install openai-whisper"
        case .timeout:
            return "Timeout na transcrição. Vídeo pode ser muito longo."
        case .processError(let message):
            return "Erro no processo Whisper: \(message)"
        }
    }
}