     1→import Foundation
     2→
     3→actor MCPService {
     4→    static let shared = MCPService()
     5→
     6→    private init() {}
     7→
     8→    // MARK: - Timeout Configuration
     9→
    10→    /// Default timeout for transcript fetching
    11→    static var defaultTimeout: TimeInterval { AppConfig.Network.transcriptTimeout }
    12→
    13→    // MARK: - Public Methods
    14→
    15→    func getVideoInfo(url: String) async throws -> VideoInfoResponse {
    16→        guard let videoID = MCPService.extractVideoID(from: url) else {
    17→            await AppLogger.shared.warning("Invalid YouTube URL: \(url)", category: .transcript)
    18→            throw MCPError.invalidURL
    19→        }
    20→
    21→        await AppLogger.shared.info("Fetching video info for: \(videoID)", category: .transcript)
    22→
    23→        let script = """
    24→        import json
    25→        from urllib.request import urlopen
    26→        from urllib.parse import urlencode
    27→
    28→        video_id = '\(videoID)'
    29→        url = f'https://www.youtube.com/oembed?{urlencode({"url": f"https://www.youtube.com/watch?v={video_id}", "format": "json"})}'
    30→
    31→        try:
    32→            response = urlopen(url)
    33→            data = json.loads(response.read().decode())
    34→            result = {
    35→                "title": data.get("title", ""),
    36→                "author": data.get("author_name", ""),
    37→                "thumbnail_url": data.get("thumbnail_url", "")
    38→            }
    39→            print(json.dumps(result))
    40→        except Exception as e:
    41→            print(json.dumps({"error": str(e)}))
    42→        """
    43→
    44→        let output = try await runPython(script: script)
    45→        guard let data = output.data(using: .utf8) else {
    46→            throw MCPError.invalidResponse
    47→        }
    48→
    49→        return try JSONDecoder().decode(VideoInfoResponse.self, from: data)
    50→    }
    51→
    52→    func getTranscript(url: String, language: String = "en") async throws -> String {
    53→        guard let videoID = MCPService.extractVideoID(from: url) else {
    54→            await AppLogger.shared.warning("Invalid YouTube URL for transcript: \(url)", category: .transcript)
    55→            throw MCPError.invalidURL
    56→        }
    57→
    58→        await AppLogger.shared.info("Fetching transcript for \(videoID) in \(language)", category: .transcript)
    59→        startTimedEvent("transcript_fetch_\(videoID)")
    60→
    61→        let script = """
    62→        from youtube_transcript_api import YouTubeTranscriptApi
    63→
    64→        video_id = '\(videoID)'
    65→        lang = '\(language)'
    66→
    67→        try:
    68→            ytt_api = YouTubeTranscriptApi()
    69→            transcript = ytt_api.fetch(video_id, languages=[lang, 'en'])
    70→            full_text = ' '.join([item.text for item in transcript])
    71→            print(full_text)
    72→        except Exception as e:
    73→            print(f"ERROR: {str(e)}")
    74→        """
    75→
    76→        let output = try await runPython(script: script)
    77→
    78→        if output.hasPrefix("ERROR:") {
    79→            throw MCPError.transcriptNotAvailable(output)
    80→        }
    81→
    82→        return output.trimmingCharacters(in: .whitespacesAndNewlines)
    83→    }
    84→
    85→    func getTimedTranscript(url: String, language: String = "en") async throws -> String {
    86→        guard let videoID = MCPService.extractVideoID(from: url) else {
    87→            throw MCPError.invalidURL
    88→        }
    89→
    90→        let script = """
    91→        from youtube_transcript_api import YouTubeTranscriptApi
    92→
    93→        video_id = '\(videoID)'
    94→        lang = '\(language)'
    95→
    96→        def format_time(seconds):
    97→            mins = int(seconds // 60)
    98→            secs = int(seconds % 60)
    99→            return f"[{mins:02d}:{secs:02d}]"
   100→
   101→        try:
   102→            ytt_api = YouTubeTranscriptApi()
   103→            transcript = ytt_api.fetch(video_id, languages=[lang, 'en'])
   104→            lines = [f"{format_time(item.start)} {item.text}" for item in transcript]
   105→            print('\\n'.join(lines))
   106→        except Exception as e:
   107→            print(f"ERROR: {str(e)}")
   108→        """
   109→
   110→        let output = try await runPython(script: script)
   111→
   112→        if output.hasPrefix("ERROR:") {
   113→            throw MCPError.transcriptNotAvailable(output)
   114→        }
   115→
   116→        return output.trimmingCharacters(in: .whitespacesAndNewlines)
   117→    }
   118→
   119→    /// Lists all available transcript languages for a video
   120→    func listAvailableLanguages(url: String) async throws -> [LanguageListResponse.LanguageInfo] {
   121→        guard let videoID = MCPService.extractVideoID(from: url) else {
   122→            await AppLogger.shared.warning("Invalid YouTube URL for language listing: \(url)", category: .transcript)
   123→            throw MCPError.invalidURL
   124→        }
   125→
   126→        await AppLogger.shared.info("Listing available languages for: \(videoID)", category: .transcript)
   127→
   128→        let script = """
   129→        from youtube_transcript_api import YouTubeTranscriptApi
   130→        import json
   131→
   132→        video_id = '\(videoID)'
   133→
   134→        try:
   135→            ytt_api = YouTubeTranscriptApi()
   136→            transcript_list = ytt_api.list(video_id)
   137→            languages = []
   138→
   139→            # Get all transcripts (manual and auto-generated)
   140→            for transcript in transcript_list:
   141→                languages.append({
   142→                    "language": transcript.language_code,
   143→                    "is_generated": transcript.is_generated,
   144→                    "is_translatable": getattr(transcript, 'is_translatable', False)
   145→                })
   146→
   147→            print(json.dumps({"languages": languages}))
   148→        except Exception as e:
   149→            print(json.dumps({"error": str(e), "languages": []}))
   150→        """

<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

actor MCPService {
    static let shared = MCPService()

    private init() {}

    // MARK: - Timeout Configuration

    /// Default timeout for transcript fetching
    static var defaultTimeout: TimeInterval { AppConfig.Network.transcriptTimeout }

    // MARK: - Public Methods

    func getVideoInfo(url: String) async throws -> VideoInfoResponse {
        guard let videoID = MCPService.extractVideoID(from: url) else {
            await AppLogger.shared.warning("Invalid YouTube URL: \(url)", category: .transcript)
            throw MCPError.invalidURL
        }

        await AppLogger.shared.info("Fetching video info for: \(videoID)", category: .transcript)

        let script = """
        import json
        from urllib.request import urlopen
        from urllib.parse import urlencode

        video_id = '\(videoID)'
        url = f'https://www.youtube.com/oembed?{urlencode({"url": f"https://www.youtube.com/watch?v={video_id}", "format": "json"})}'

        try:
            response = urlopen(url)
            data = json.loads(response.read().decode())
            result = {
                "title": data.get("title", ""),
                "author": data.get("author_name", ""),
                "thumbnail_url": data.get("thumbnail_url", "")
            }
            print(json.dumps(result))
        except Exception as e:
            print(json.dumps({"error": str(e)}))
        """

        let output = try await runPython(script: script)
        guard let data = output.data(using: .utf8) else {
            throw MCPError.invalidResponse
        }

        return try JSONDecoder().decode(VideoInfoResponse.self, from: data)
    }

    func getTranscript(url: String, language: String = "en") async throws -> String {
        guard let videoID = MCPService.extractVideoID(from: url) else {
            await AppLogger.shared.warning("Invalid YouTube URL for transcript: \(url)", category: .transcript)
            throw MCPError.invalidURL
        }

        await AppLogger.shared.info("Fetching transcript for \(videoID) in \(language)", category: .transcript)
        startTimedEvent("transcript_fetch_\(videoID)")

        let script = """
        from youtube_transcript_api import YouTubeTranscriptApi

        video_id = '\(videoID)'
        lang = '\(language)'

        try:
            ytt_api = YouTubeTranscriptApi()
            transcript = ytt_api.fetch(video_id, languages=[lang, 'en'])
            full_text = ' '.join([item.text for item in transcript])
            print(full_text)
        except Exception as e:
            print(f"ERROR: {str(e)}")
        """

        let output = try await runPython(script: script)

        if output.hasPrefix("ERROR:") {
            throw MCPError.transcriptNotAvailable(output)
        }

        return output.trimmingCharacters(in: .whitespacesAndNewlines)
    }

    func getTimedTranscript(url: String, language: String = "en") async throws -> String {
        guard let videoID = MCPService.extractVideoID(from: url) else {
            throw MCPError.invalidURL
        }

        let script = """
        from youtube_transcript_api import YouTubeTranscriptApi

        video_id = '\(videoID)'
        lang = '\(language)'

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

        try:
            ytt_api = YouTubeTranscriptApi()
            transcript = ytt_api.fetch(video_id, languages=[lang, 'en'])
            lines = [f"{format_time(item.start)} {item.text}" for item in transcript]
            print('\\n'.join(lines))
        except Exception as e:
            print(f"ERROR: {str(e)}")
        """

        let output = try await runPython(script: script)

        if output.hasPrefix("ERROR:") {
            throw MCPError.transcriptNotAvailable(output)
        }

        return output.trimmingCharacters(in: .whitespacesAndNewlines)
    }

    /// Lists all available transcript languages for a video
    func listAvailableLanguages(url: String) async throws -> [LanguageListResponse.LanguageInfo] {
        guard let videoID = MCPService.extractVideoID(from: url) else {
            await AppLogger.shared.warning("Invalid YouTube URL for language listing: \(url)", category: .transcript)
            throw MCPError.invalidURL
        }

        await AppLogger.shared.info("Listing available languages for: \(videoID)", category: .transcript)

        let script = """
        from youtube_transcript_api import YouTubeTranscriptApi
        import json

        video_id = '\(videoID)'

        try:
            ytt_api = YouTubeTranscriptApi()
            transcript_list = ytt_api.list(video_id)
            languages = []

            # Get all transcripts (manual and auto-generated)
            for transcript in transcript_list:
                languages.append({
                    "language": transcript.language_code,
                    "is_generated": transcript.is_generated,
                    "is_translatable": getattr(transcript, 'is_translatable', False)
                })

            print(json.dumps({"languages": languages}))
        except Exception as e:
            print(json.dumps({"error": str(e), "languages": []}))
        """


Let me continue reading the MCPService and look for batch processing patterns:


Bash

Get total lines in MCPService


848 /Users/neog/Apps/macOS/YouTubeTranscripts-macOS/Sources/Services/MCPService.swift


   150→        """
   151→
   152→        let output = try await runPython(script: script)
   153→        guard let data = output.data(using: .utf8) else {
   154→            throw MCPError.invalidResponse
   155→        }
   156→
   157→        let response = try JSONDecoder().decode(LanguageListResponse.self, from: data)
   158→
   159→        if let error = response.error, !error.isEmpty {
   160→            await AppLogger.shared.warning("Language listing error: \(error)", category: .transcript)
   161→        }
   162→
   163→        await AppLogger.shared.info("Found \(response.languages.count) available languages", category: .transcript)
   164→
   165→        return response.languages
   166→    }
   167→
   168→    // MARK: - Public Methods with Progress
   169→
   170→    /// Fetches transcript with progress callback for long videos
   171→    func getTranscriptWithProgress(
   172→        url: String,
   173→        language: String = "en",
   174→        timed: Bool = false,
   175→        timeout: TimeInterval = MCPService.defaultTimeout,
   176→        progress: @escaping @Sendable (String) -> Void
   177→    ) async throws -> String {
   178→        guard let videoID = MCPService.extractVideoID(from: url) else {
   179→            await AppLogger.shared.warning("Invalid YouTube URL: \(url)", category: .transcript)
   180→            trackEvent(.transcriptFetchFailed, properties: ["reason": "invalid_url"])
   181→            throw MCPError.invalidURL
   182→        }
   183→
   184→        await AppLogger.shared.info("Starting transcript fetch with progress for \(videoID)", category: .transcript, metadata: [
   185→            "language": language,
   186→            "timed": String(timed),
   187→            "timeout": String(Int(timeout))
   188→        ])
   189→
   190→        startTimedEvent("transcript_fetch_\(videoID)")
   191→
   192→        // STEP 1: Detect available languages
   193→        await MainActor.run { progress("🔍 Verificando idiomas disponíveis...") }
   194→
   195→        let availableLanguageInfos: [LanguageListResponse.LanguageInfo]
   196→        do {
   197→            availableLanguageInfos = try await listAvailableLanguages(url: url)
   198→        } catch {
   199→            await AppLogger.shared.error("Failed to list languages: \(error.localizedDescription)", category: .transcript)
   200→            // If we can't list languages, fall back to original behavior (try user language + en)
   201→            availableLanguageInfos = []
   202→        }
   203→
   204→        let availableLanguages = availableLanguageInfos.map { $0.language }
   205→
   206→        // STEP 2: Check if any transcripts are available
   207→        if availableLanguages.isEmpty {
   208→            await AppLogger.shared.warning("No transcripts available for video \(videoID)", category: .transcript)
   209→            trackEvent(.transcriptFetchFailed, properties: [
   210→                "reason": "no_transcripts_available",
   211→                "video_id": videoID
   212→            ])
   213→            throw MCPError.noTranscriptsAvailable(videoID: videoID)
   214→        }
   215→
   216→        // STEP 3: Show available languages to user
   217→        let langList = availableLanguages.joined(separator: ", ")
   218→        await MainActor.run { progress("📋 Idiomas encontrados: \(langList)") }
   219→        await AppLogger.shared.info("Available languages: \(langList)", category: .transcript)
   220→
   221→        // STEP 4: Build priority list of languages to try
   222→        let priorityLanguages = buildLanguagePriorityList(
   223→            userPreferred: language,
   224→            available: availableLanguages
   225→        )
   226→
   227→        await AppLogger.shared.info("Language priority: \(priorityLanguages.joined(separator: " → "))", category: .transcript)
   228→
   229→        // STEP 5: Try each language in order
   230→        var lastError: Error?
   231→
   232→        for (index, lang) in priorityLanguages.enumerated() {
   233→            await MainActor.run { progress("🌐 Tentando buscar em \(lang)... (\(index + 1)/\(priorityLanguages.count))") }
   234→
   235→            do {
   236→                let transcript = try await fetchTranscriptInLanguage(
   237→                    videoID: videoID,
   238→                    language: lang,
   239→                    timed: timed,
   240→                    timeout: timeout,
   241→                    progress: progress
   242→                )
   243→
   244→                // SUCCESS!
   245→                await MainActor.run { progress("✅ Transcrição obtida em \(lang)!") }
   246→
   247→                await AppLogger.shared.info("Transcript successfully fetched in \(lang)", category: .transcript, metadata: [
   248→                    "video_id": videoID,
   249→                    "language_used": lang,
   250→                    "was_preferred": String(lang == language),
   251→                    "attempt_number": String(index + 1)
   252→                ])
   253→
   254→                endTimedEvent("transcript_fetch_\(videoID)", event: .transcriptFetchCompleted, properties: [
   255→                    "video_id": videoID,
   256→                    "language": lang,
   257→                    "timed": String(timed)
   258→                ])
   259→
   260→                return transcript
   261→            } catch {
   262→                lastError = error
   263→                await AppLogger.shared.warning("Failed to fetch in \(lang): \(error.localizedDescription)", category: .transcript)
   264→                continue
   265→            }
   266→        }
   267→
   268→        // STEP 6: All languages failed
   269→        await AppLogger.shared.error("Failed to fetch transcript in all available languages", category: .transcript, metadata: [
   270→            "video_id": videoID,
   271→            "tried_languages": priorityLanguages.joined(separator: ", ")
   272→        ])
   273→
   274→        trackEvent(.transcriptFetchFailed, properties: [
   275→            "reason": "all_languages_failed",
   276→            "video_id": videoID,
   277→            "languages_tried": priorityLanguages.joined(separator: ", ")
   278→        ])
   279→
   280→        throw MCPError.allLanguagesFailed(available: availableLanguages, lastError: lastError)
   281→    }
   282→
   283→    /// Internal helper to fetch transcript in a specific language
   284→    private func fetchTranscriptInLanguage(
   285→        videoID: String,
   286→        language: String,
   287→        timed: Bool,
   288→        timeout: TimeInterval,
   289→        progress: @escaping @Sendable (String) -> Void
   290→    ) async throws -> String {
   291→        let script: String
   292→        if timed {
   293→            script = """
   294→            import sys
   295→            sys.stdout.reconfigure(line_buffering=True)
   296→            from youtube_transcript_api import YouTubeTranscriptApi
   297→
   298→            video_id = '\(videoID)'
   299→            lang = '\(language)'
   300→
   301→            def format_time(seconds):
   302→                mins = int(seconds // 60)
   303→                secs = int(seconds % 60)
   304→                return f"[{mins:02d}:{secs:02d}]"
   305→
   306→            try:
   307→                print("PROGRESS: Conectando à API do YouTube...", flush=True)
   308→                ytt_api = YouTubeTranscriptApi()
   309→                print("PROGRESS: Buscando transcrição...", flush=True)
   310→                transcript = ytt_api.fetch(video_id, languages=[lang])
   311→                print("PROGRESS: Processando texto...", flush=True)
   312→                lines = [f"{format_time(item.start)} {item.text}" for item in transcript]
   313→                print("PROGRESS: Formatando resultado...", flush=True)
   314→                result = '\\n'.join(lines)
   315→                print("TRANSCRIPT_START")
   316→                print(result)
   317→                print("TRANSCRIPT_END")
   318→            except Exception as e:
   319→                print(f"ERROR: {str(e)}")
   320→            """
   321→        } else {
   322→            script = """
   323→            import sys
   324→            sys.stdout.reconfigure(line_buffering=True)
   325→            from youtube_transcript_api import YouTubeTranscriptApi
   326→
   327→            video_id = '\(videoID)'
   328→            lang = '\(language)'
   329→
   330→            try:
   331→                print("PROGRESS: Conectando à API do YouTube...", flush=True)
   332→                ytt_api = YouTubeTranscriptApi()
   333→                print("PROGRESS: Buscando transcrição...", flush=True)
   334→                transcript = ytt_api.fetch(video_id, languages=[lang])
   335→                print("PROGRESS: Processando texto...", flush=True)
   336→                full_text = ' '.join([item.text for item in transcript])
   337→                print("PROGRESS: Finalizando...", flush=True)
   338→                print("TRANSCRIPT_START")
   339→                print(full_text)
   340→                print("TRANSCRIPT_END")
   341→            except Exception as e:
   342→                print(f"ERROR: {str(e)}")
   343→            """
   344→        }
   345→
   346→        let output = try await runPythonWithProgress(
   347→            script: script,
   348→            timeout: timeout,
   349→            progress: progress
   350→        )
   351→
   352→        // Extract transcript from output - check for errors first
   353→        if output.contains("ERROR:") {
   354→            // Extract error message and truncate if too long
   355→            let errorMsg = output
   356→                .components(separatedBy: "ERROR: ")
   357→                .dropFirst()
   358→                .first?
   359→                .components(separatedBy: "\n")
   360→                .first ?? "Unknown error"
   361→            throw MCPError.transcriptNotAvailable(String(errorMsg.prefix(500)))
   362→        }
   363→
   364→        // Parse the transcript from markers
   365→        if let startRange = output.range(of: "TRANSCRIPT_START\n"),
   366→           let endRange = output.range(of: "\nTRANSCRIPT_END") {
   367→            let transcript = String(output[startRange.upperBound..<endRange.lowerBound])
   368→            let result = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
   369→
   370→            // Validate we got actual content
   371→            guard !result.isEmpty else {
   372→                throw MCPError.transcriptNotAvailable("Empty transcript received")
   373→            }
   374→
   375→            // Log success with metrics
   376→            let wordCount = result.split(separator: " ").count
   377→            await AppLogger.shared.info("Transcript chunk fetched", category: .transcript, metadata: [
   378→                "video_id": videoID,
   379→                "language": language,
   380→                "word_count": String(wordCount),
   381→                "char_count": String(result.count)
   382→            ])
   383→
   384→            // Record metric
   385→            Task { await AppLogger.shared.recordMetric(name: "transcript_word_count", value: Double(wordCount)) }
   386→
   387→            return result
   388→        }
   389→
   390→        // No markers found - check if output looks like valid transcript
   391→        let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines)
   392→        guard !trimmedOutput.isEmpty else {
   393→            throw MCPError.invalidResponse
   394→        }
   395→
   396→        return trimmedOutput
   397→    }
   398→
   399→    /// Fetches transcript with automatic fallback to Whisper if no captions available
   400→    /// This method tries YouTube captions first, then falls back to Whisper transcription
   401→    /// - Parameters:
   402→    ///   - url: YouTube video URL
   403→    ///   - language: Preferred language code (e.g. "en", "pt", "es")
   404→    ///   - timed: Whether to include timestamps
   405→    ///   - timeout: Timeout for each operation
   406→    ///   - progress: Progress callback
   407→    /// - Returns: Tuple with transcript text and source indicator
   408→    func getTranscriptWithWhisperFallback(
   409→        url: String,
   410→        language: String = "en",
   411→        timed: Bool = false,
   412→        timeout: TimeInterval = MCPService.defaultTimeout,
   413→        progress: @escaping @Sendable (String) -> Void
   414→    ) async throws -> (transcript: String, source: TranscriptSource) {
   415→
   416→        guard let videoID = MCPService.extractVideoID(from: url) else {
   417→            throw MCPError.invalidURL
   418→        }
   419→
   420→        await AppLogger.shared.info("Starting transcript fetch with Whisper fallback", category: .transcript, metadata: [
   421→            "video_id": videoID,
   422→            "language": language
   423→        ])
   424→
   425→        // STEP 1: Try YouTube captions first (fastest)
   426→        do {
   427→            let transcript = try await getTranscriptWithProgress(
   428→                url: url,
   429→                language: language,
   430→                timed: timed,
   431→                timeout: timeout,
   432→                progress: progress
   433→            )
   434→
   435→            await AppLogger.shared.info("Transcript obtained from YouTube captions", category: .transcript)
   436→            return (transcript, .youtubeCaption)
   437→
   438→        } catch MCPError.noTranscriptsAvailable {
   439→            // No captions available - fallback to Whisper
   440→            await MainActor.run {
   441→                progress("⚠ Nenhuma legenda disponível. Iniciando transcrição automática via Whisper...")
   442→            }
   443→
   444→            await AppLogger.shared.info("No YouTube captions available, falling back to Whisper", category: .transcript)
   445→
   446→            // STEP 2: Download audio
   447→            await MainActor.run {
   448→                progress("📥 Fase 1/3: Baixando áudio do vídeo...")
   449→            }
   450→
   451→            let audioPath: URL
   452→            do {
   453→                audioPath = try await AudioDownloadService.shared.downloadAudio(
   454→                    url: url,
   455→                    progress: progress
   456→                )
   457→            } catch {
   458→                await AppLogger.shared.error("Audio download failed: \(error.localizedDescription)", category: .transcript)
   459→                throw error
   460→            }
   461→
   462→            // STEP 3: Transcribe with Whisper
   463→            await MainActor.run {
   464→                progress("🤖 Fase 2/3: Transcrevendo áudio (pode demorar alguns minutos)...")
   465→            }
   466→
   467→            let transcript: String
   468→            do {
   469→                // Convert language code to Whisper format
   470→                let whisperLanguage: String? = {
   471→                    switch language {
   472→                    case "pt", "pt-BR", "pt-PT": return "portuguese"
   473→                    case "es", "es-ES", "es-MX": return "spanish"
   474→                    case "fr", "fr-FR", "fr-CA": return "french"
   475→                    case "de", "de-DE": return "german"
   476→                    case "it", "it-IT": return "italian"
   477→                    case "ja", "ja-JP": return "japanese"
   478→                    case "ko", "ko-KR": return "korean"
   479→                    case "zh", "zh-CN", "zh-TW": return "chinese"
   480→                    case "en", "en-US", "en-GB": return nil  // Auto-detect
   481→                    default: return nil
   482→                    }
   483→                }()
   484→
   485→                if timed {
   486→                    transcript = try await WhisperService.shared.transcribeWithTimestamps(
   487→                        audioPath: audioPath,
   488→                        language: whisperLanguage,
   489→                        modelSize: WhisperService.ModelSize.base,
   490→                        progress: progress
   491→                    )
   492→                } else {
   493→                    transcript = try await WhisperService.shared.transcribe(
   494→                        audioPath: audioPath,
   495→                        language: whisperLanguage,
   496→                        modelSize: WhisperService.ModelSize.base,
   497→                        progress: progress
   498→                    )
   499→                }
   500→            } catch {
   501→                // Cleanup on error
   502→                try? FileManager.default.removeItem(at: audioPath)
   503→                await AppLogger.shared.error("Whisper transcription failed: \(error.localizedDescription)", category: .transcript)
   504→                throw error
   505→            }
   506→
   507→            // STEP 4: Cleanup temp file
   508→            await MainActor.run {
   509→                progress("🧹 Fase 3/3: Finalizando...")
   510→            }
   511→
   512→            try? FileManager.default.removeItem(at: audioPath)
   513→
   514→            await AppLogger.shared.info("Transcript obtained via Whisper", category: .transcript, metadata: [
   515→                "word_count": String(transcript.split(separator: " ").count)
   516→            ])
   517→
   518→            await MainActor.run { progress("✅ Transcrição concluída via Whisper!") }
   519→
   520→            return (transcript, .whisperTranscription)
   521→
   522→        } catch {
   523→            // Other errors - propagate
   524→            await AppLogger.shared.error("Transcript fetch failed: \(error.localizedDescription)", category: .transcript)
   525→            throw error
   526→        }
   527→    }
   528→
   529→    // MARK: - Private Methods
   530→
   531→    /// Builds a priority list of languages to try, based on user preference and common fallbacks
   532→    private func buildLanguagePriorityList(
   533→        userPreferred: String,
   534→        available: [String]
   535→    ) -> [String] {
   536→        // Build ordered priority list
   537→        var priority: [String] = []
   538→
   539→        // 1. User's preferred language (if available)
   540→        if available.contains(userPreferred) {
   541→            priority.append(userPreferred)
   542→        }
   543→
   544→        // 2. Portuguese variants
   545→        let portugueseVariants = ["pt", "pt-BR", "pt-PT"]
   546→        for variant in portugueseVariants {
   547→            if available.contains(variant) && !priority.contains(variant) {
   548→                priority.append(variant)
   549→            }
   550→        }
   551→
   552→        // 3. English variants
   553→        let englishVariants = ["en", "en-US", "en-GB", "en-AU"]
   554→        for variant in englishVariants {
   555→            if available.contains(variant) && !priority.contains(variant) {
   556→                priority.append(variant)
   557→            }
   558→        }
   559→
   560→        // 4. Spanish variants
   561→        let spanishVariants = ["es", "es-ES", "es-MX"]
   562→        for variant in spanishVariants {
   563→            if available.contains(variant) && !priority.contains(variant) {
   564→                priority.append(variant)
   565→            }
   566→        }
   567→
   568→        // 5. French variants
   569→        let frenchVariants = ["fr", "fr-FR", "fr-CA"]
   570→        for variant in frenchVariants {
   571→            if available.contains(variant) && !priority.contains(variant) {
   572→                priority.append(variant)
   573→            }
   574→        }
   575→
   576→        // 6. Any other available languages
   577→        for lang in available {
   578→            if !priority.contains(lang) {
   579→                priority.append(lang)
   580→            }
   581→        }
   582→
   583→        return priority
   584→    }
   585→
   586→    // MARK: - Process Data Buffer
   587→
   588→    /// Actor-based buffer for thread-safe process output collection
   589→    private actor ProcessDataBuffer {
   590→        private var data = Data()
   591→        private var isCompleted = false
   592→
   593→        func append(_ newData: Data) {
   594→            data.append(newData)
   595→        }
   596→
   597→        func getData() -> Data {
   598→            return data
   599→        }
   600→
   601→        func markCompleted() -> Bool {
   602→            if isCompleted {
   603→                return true  // Already completed
   604→            }
   605→            isCompleted = true
   606→            return false  // First to complete
   607→        }
   608→
   609→        func checkCompleted() -> Bool {
   610→            return isCompleted
   611→        }
   612→    }
   613→
   614→    private func runPythonWithProgress(
   615→        script: String,
   616→        timeout: TimeInterval,
   617→        progress: @escaping @Sendable (String) -> Void
   618→    ) async throws -> String {
   619→        return try await withCheckedThrowingContinuation { continuation in
   620→            let process = Process()
   621→            // Use configured Python executable
   622→            process.executableURL = URL(fileURLWithPath: AppConfig.Executables.pythonPath)
   623→            process.arguments = ["-c", script]
   624→
   625→            let outputPipe = Pipe()
   626→            let errorPipe = Pipe()
   627→            process.standardOutput = outputPipe
   628→            process.standardError = errorPipe
   629→
   630→            // ✅ Actor-isolated buffer (thread-safe)
   631→            let buffer = ProcessDataBuffer()
   632→
   633→            // Read output in real-time for progress updates
   634→            outputPipe.fileHandleForReading.readabilityHandler = { handle in
   635→                let data = handle.availableData
   636→                if !data.isEmpty {
   637→                    Task {
   638→                        await buffer.append(data)
   639→                    }
   640→
   641→                    if let text = String(data: data, encoding: .utf8) {
   642→                        // Parse progress messages
   643→                        let lines = text.components(separatedBy: "\n")
   644→                        for line in lines {
   645→                            if line.hasPrefix("PROGRESS: ") {
   646→                                let message = String(line.dropFirst(10))
   647→                                Task { @MainActor in
   648→                                    progress("📡 \(message)")
   649→                                }
   650→                            }
   651→                        }
   652→                    }
   653→                }
   654→            }
   655→
   656→            // Timeout timer
   657→            let timeoutTimer = DispatchSource.makeTimerSource(queue: .global())
   658→            timeoutTimer.schedule(deadline: .now() + timeout)
   659→            timeoutTimer.setEventHandler {
   660→                Task {
   661→                    let alreadyCompleted = await buffer.checkCompleted()
   662→                    if !alreadyCompleted {
   663→                        _ = await buffer.markCompleted()
   664→                        process.terminate()
   665→                        Task { @MainActor in
   666→                            progress("⚠ Timeout - operação demorou mais de \(Int(timeout/60)) minutos")
   667→                        }
   668→                        continuation.resume(throwing: MCPError.timeout)
   669→                    }
   670→                }
   671→            }
   672→            timeoutTimer.resume()
   673→
   674→            process.terminationHandler = { proc in
   675→                timeoutTimer.cancel()
   676→                outputPipe.fileHandleForReading.readabilityHandler = nil
   677→
   678→                Task {
   679→                    let alreadyCompleted = await buffer.markCompleted()
   680→                    if alreadyCompleted {
   681→                        return  // Timeout already handled
   682→                    }
   683→
   684→                    // Read any remaining data
   685→                    let remainingData = outputPipe.fileHandleForReading.readDataToEndOfFile()
   686→                    await buffer.append(remainingData)
   687→
   688→                    let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
   689→
   690→                    if proc.terminationStatus != 0 {
   691→                        let errorString = String(data: errorData, encoding: .utf8) ?? "Unknown error"
   692→                        continuation.resume(throwing: MCPError.processError(errorString))
   693→                    } else {
   694→                        let outputData = await buffer.getData()
   695→                        guard let output = String(data: outputData, encoding: .utf8) else {
   696→                            continuation.resume(throwing: MCPError.invalidResponse)
   697→                            return
   698→                        }
   699→                        continuation.resume(returning: output)
   700→                    }
   701→                }
   702→            }
   703→
   704→            do {
   705→                try process.run()
   706→            } catch {
   707→                timeoutTimer.cancel()
   708→                continuation.resume(throwing: MCPError.processError(error.localizedDescription))
   709→            }
   710→        }
   711→    }
   712→
   713→    private func runPython(script: String) async throws -> String {
   714→        let process = Process()
   715→        let outputPipe = Pipe()
   716→        let errorPipe = Pipe()
   717→
   718→        // Use configured Python executable
   719→        process.executableURL = URL(fileURLWithPath: AppConfig.Executables.pythonPath)
   720→        process.arguments = ["-c", script]
   721→        process.standardOutput = outputPipe
   722→        process.standardError = errorPipe
   723→
   724→        try process.run()
   725→        process.waitUntilExit()
   726→
   727→        let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
   728→        let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
   729→
   730→        if process.terminationStatus != 0 {
   731→            let errorString = String(data: errorData, encoding: .utf8) ?? "Unknown error"
   732→            throw MCPError.processError(errorString)
   733→        }
   734→
   735→        guard let output = String(data: outputData, encoding: .utf8) else {
   736→            throw MCPError.invalidResponse
   737→        }
   738→
   739→        return output
   740→    }
   741→}
   742→
   743→// MARK: - Response Types
   744→
   745→struct VideoInfoResponse: Codable {
   746→    let title: String?
   747→    let author: String?
   748→    let thumbnail_url: String?
   749→    let error: String?
   750→
   751→    var channelName: String {
   752→        author ?? "Unknown"
   753→    }
   754→}
   755→
   756→struct LanguageListResponse: Codable {
   757→    struct LanguageInfo: Codable {
   758→        let language: String
   759→        let is_generated: Bool
   760→        let is_translatable: Bool
   761→    }
   762→
   763→    let languages: [LanguageInfo]
   764→    let error: String?
   765→}
   766→
   767→// MARK: - Transcript Source
   768→
   769→enum TranscriptSource {
   770→    case youtubeCaption
   771→    case whisperTranscription
   772→}
   773→
   774→// MARK: - Errors
   775→
   776→enum MCPError: LocalizedError {
   777→    case processError(String)
   778→    case invalidResponse
   779→    case invalidURL
   780→    case transcriptNotAvailable(String)
   781→    case timeout
   782→    case noTranscriptsAvailable(videoID: String)
   783→    case allLanguagesFailed(available: [String], lastError: Error?)
   784→
   785→    var errorDescription: String? {
   786→        switch self {
   787→        case .processError(let message):
   788→            return "Python Error: \(message)"
   789→        case .invalidResponse:
   790→            return "Invalid response from Python script"
   791→        case .invalidURL:
   792→            return "Invalid YouTube URL"
   793→        case .transcriptNotAvailable(let message):
   794→            return message
   795→        case .timeout:
   796→            return "A operação excedeu o tempo limite. Tente novamente ou verifique sua conexão."
   797→        case .noTranscriptsAvailable(let videoID):
   798→            return """
   799→            ❌ Nenhuma legenda disponível para este vídeo.
   800→
   801→            Vídeo ID: \(videoID)
   802→
   803→            Este vídeo não possui legendas ou transcrições no YouTube.
   804→            Possíveis razões:
   805→            • O criador desabilitou legendas
   806→            • O vídeo é muito recente (legendas ainda sendo processadas)
   807→            • Erro temporário do YouTube
   808→
   809→            Aguarde algumas horas e tente novamente.
   810→            """
   811→        case .allLanguagesFailed(let available, let lastError):
   812→            let langs = available.joined(separator: ", ")
   813→            let errorDetail = lastError?.localizedDescription ?? "Unknown error"
   814→            return """
   815→            ⚠ Falha ao buscar transcrição em todos os idiomas disponíveis.
   816→
   817→            Idiomas tentados: \(langs)
   818→
   819→            Último erro: \(errorDetail)
   820→
   821→            Tente novamente mais tarde.
   822→            """
   823→        }
   824→    }
   825→}
   826→
   827→// MARK: - URL Helpers
   828→
   829→extension MCPService {
   830→    static func extractVideoID(from url: String) -> String? {
   831→        let patterns = [
   832→            "(?:youtube\\.com/watch\\?v=)([a-zA-Z0-9_-]{11})",
   833→            "(?:youtu\\.be/)([a-zA-Z0-9_-]{11})",
   834→            "(?:youtube\\.com/embed/)([a-zA-Z0-9_-]{11})",
   835→            "(?:youtube\\.com/v/)([a-zA-Z0-9_-]{11})"
   836→        ]
   837→
   838→        for pattern in patterns {
   839→            if let regex = try? NSRegularExpression(pattern: pattern),
   840→               let match = regex.firstMatch(in: url, range: NSRange(url.startIndex..., in: url)),
   841→               let range = Range(match.range(at: 1), in: url) {
   842→                return String(url[range])
   843→            }
   844→        }
   845→
   846→        return nil
   847→    }
   848→}
   849→

<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>

        """

        let output = try await runPython(script: script)
        guard let data = output.data(using: .utf8) else {
            throw MCPError.invalidResponse
        }

        let response = try JSONDecoder().decode(LanguageListResponse.self, from: data)

        if let error = response.error, !error.isEmpty {
            await AppLogger.shared.warning("Language listing error: \(error)", category: .transcript)
        }

        await AppLogger.shared.info("Found \(response.languages.count) available languages", category: .transcript)

        return response.languages
    }

    // MARK: - Public Methods with Progress

    /// Fetches transcript with progress callback for long videos
    func getTranscriptWithProgress(
        url: String,
        language: String = "en",
        timed: Bool = false,
        timeout: TimeInterval = MCPService.defaultTimeout,
        progress: @escaping @Sendable (String) -> Void
    ) async throws -> String {
        guard let videoID = MCPService.extractVideoID(from: url) else {
            await AppLogger.shared.warning("Invalid YouTube URL: \(url)", category: .transcript)
            trackEvent(.transcriptFetchFailed, properties: ["reason": "invalid_url"])
            throw MCPError.invalidURL
        }

        await AppLogger.shared.info("Starting transcript fetch with progress for \(videoID)", category: .transcript, metadata: [
            "language": language,
            "timed": String(timed),
            "timeout": String(Int(timeout))
        ])

        startTimedEvent("transcript_fetch_\(videoID)")

        // STEP 1: Detect available languages
        await MainActor.run { progress("🔍 Verificando idiomas disponíveis...") }

        let availableLanguageInfos: [LanguageListResponse.LanguageInfo]
        do {
            availableLanguageInfos = try await listAvailableLanguages(url: url)
        } catch {
            await AppLogger.shared.error("Failed to list languages: \(error.localizedDescription)", category: .transcript)
            // If we can't list languages, fall back to original behavior (try user language + en)
            availableLanguageInfos = []
        }

        let availableLanguages = availableLanguageInfos.map { $0.language }

        // STEP 2: Check if any transcripts are available
        if availableLanguages.isEmpty {
            await AppLogger.shared.warning("No transcripts available for video \(videoID)", category: .transcript)
            trackEvent(.transcriptFetchFailed, properties: [
                "reason": "no_transcripts_available",
                "video_id": videoID
            ])
            throw MCPError.noTranscriptsAvailable(videoID: videoID)
        }

        // STEP 3: Show available languages to user
        let langList = availableLanguages.joined(separator: ", ")
        await MainActor.run { progress("📋 Idiomas encontrados: \(langList)") }
        await AppLogger.shared.info("Available languages: \(langList)", category: .transcript)

        // STEP 4: Build priority list of languages to try
        let priorityLanguages = buildLanguagePriorityList(
            userPreferred: language,
            available: availableLanguages
        )

        await AppLogger.shared.info("Language priority: \(priorityLanguages.joined(separator: " → "))", category: .transcript)

        // STEP 5: Try each language in order
        var lastError: Error?

        for (index, lang) in priorityLanguages.enumerated() {
            await MainActor.run { progress("🌐 Tentando buscar em \(lang)... (\(index + 1)/\(priorityLanguages.count))") }

            do {
                let transcript = try await fetchTranscriptInLanguage(
                    videoID: videoID,
                    language: lang,
                    timed: timed,
                    timeout: timeout,
                    progress: progress
                )

                // SUCCESS!
                await MainActor.run { progress("✅ Transcrição obtida em \(lang)!") }

                await AppLogger.shared.info("Transcript successfully fetched in \(lang)", category: .transcript, metadata: [
                    "video_id": videoID,
                    "language_used": lang,
                    "was_preferred": String(lang == language),
                    "attempt_number": String(index + 1)
                ])

                endTimedEvent("transcript_fetch_\(videoID)", event: .transcriptFetchCompleted, properties: [
                    "video_id": videoID,
                    "language": lang,
                    "timed": String(timed)
                ])

                return transcript
            } catch {
                lastError = error
                await AppLogger.shared.warning("Failed to fetch in \(lang): \(error.localizedDescription)", category: .transcript)
                continue
            }
        }

        // STEP 6: All languages failed
        await AppLogger.shared.error("Failed to fetch transcript in all available languages", category: .transcript, metadata: [
            "video_id": videoID,
            "tried_languages": priorityLanguages.joined(separator: ", ")
        ])

        trackEvent(.transcriptFetchFailed, properties: [
            "reason": "all_languages_failed",
            "video_id": videoID,
            "languages_tried": priorityLanguages.joined(separator: ", ")
        ])

        throw MCPError.allLanguagesFailed(available: availableLanguages, lastError: lastError)
    }

    /// Internal helper to fetch transcript in a specific language
    private func fetchTranscriptInLanguage(
        videoID: String,
        language: String,
        timed: Bool,
        timeout: TimeInterval,
        progress: @escaping @Sendable (String) -> Void
    ) async throws -> String {
        let script: String
        if timed {
            script = """
            import sys
            sys.stdout.reconfigure(line_buffering=True)
            from youtube_transcript_api import YouTubeTranscriptApi

            video_id = '\(videoID)'
            lang = '\(language)'

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

            try:
                print("PROGRESS: Conectando à API do YouTube...", flush=True)
                ytt_api = YouTubeTranscriptApi()
                print("PROGRESS: Buscando transcrição...", flush=True)
                transcript = ytt_api.fetch(video_id, languages=[lang])
                print("PROGRESS: Processando texto...", flush=True)
                lines = [f"{format_time(item.start)} {item.text}" for item in transcript]
                print("PROGRESS: Formatando resultado...", flush=True)
                result = '\\n'.join(lines)
                print("TRANSCRIPT_START")
                print(result)
                print("TRANSCRIPT_END")
            except Exception as e:
                print(f"ERROR: {str(e)}")
            """
        } else {
            script = """
            import sys
            sys.stdout.reconfigure(line_buffering=True)
            from youtube_transcript_api import YouTubeTranscriptApi

            video_id = '\(videoID)'
            lang = '\(language)'

            try:
                print("PROGRESS: Conectando à API do YouTube...", flush=True)
                ytt_api = YouTubeTranscriptApi()
                print("PROGRESS: Buscando transcrição...", flush=True)
                transcript = ytt_api.fetch(video_id, languages=[lang])
                print("PROGRESS: Processando texto...", flush=True)
                full_text = ' '.join([item.text for item in transcript])
                print("PROGRESS: Finalizando...", flush=True)
                print("TRANSCRIPT_START")
                print(full_text)
                print("TRANSCRIPT_END")
            except Exception as e:
                print(f"ERROR: {str(e)}")
            """
        }

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

        // Extract transcript from output - check for errors first
        if output.contains("ERROR:") {
            // Extract error message and truncate if too long
            let errorMsg = output
                .components(separatedBy: "ERROR: ")
                .dropFirst()
                .first?
                .components(separatedBy: "\n")
                .first ?? "Unknown error"
            throw MCPError.transcriptNotAvailable(String(errorMsg.prefix(500)))
        }

        // Parse the transcript from markers
        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)

            // Validate we got actual content
            guard !result.isEmpty else {
                throw MCPError.transcriptNotAvailable("Empty transcript received")
            }

            // Log success with metrics
            let wordCount = result.split(separator: " ").count
            await AppLogger.shared.info("Transcript chunk fetched", category: .transcript, metadata: [
                "video_id": videoID,
                "language": language,
                "word_count": String(wordCount),
                "char_count": String(result.count)
            ])

            // Record metric
            Task { await AppLogger.shared.recordMetric(name: "transcript_word_count", value: Double(wordCount)) }

            return result
        }

        // No markers found - check if output looks like valid transcript
        let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmedOutput.isEmpty else {
            throw MCPError.invalidResponse
        }

        return trimmedOutput
    }

    /// Fetches transcript with automatic fallback to Whisper if no captions available
    /// This method tries YouTube captions first, then falls back to Whisper transcription
    /// - Parameters:
    ///   - url: YouTube video URL
    ///   - language: Preferred language code (e.g. "en", "pt", "es")
    ///   - timed: Whether to include timestamps
    ///   - timeout: Timeout for each operation
    ///   - progress: Progress callback
    /// - Returns: Tuple with transcript text and source indicator
    func getTranscriptWithWhisperFallback(
        url: String,
        language: String = "en",
        timed: Bool = false,
        timeout: TimeInterval = MCPService.defaultTimeout,
        progress: @escaping @Sendable (String) -> Void
    ) async throws -> (transcript: String, source: TranscriptSource) {

        guard let videoID = MCPService.extractVideoID(from: url) else {
            throw MCPError.invalidURL
        }

        await AppLogger.shared.info("Starting transcript fetch with Whisper fallback", category: .transcript, metadata: [
            "video_id": videoID,
            "language": language
        ])

        // STEP 1: Try YouTube captions first (fastest)
        do {
            let transcript = try await getTranscriptWithProgress(
                url: url,
                language: language,
                timed: timed,
                timeout: timeout,
                progress: progress
            )

            await AppLogger.shared.info("Transcript obtained from YouTube captions", category: .transcript)
            return (transcript, .youtubeCaption)

        } catch MCPError.noTranscriptsAvailable {
            // No captions available - fallback to Whisper
            await MainActor.run {
                progress("⚠ Nenhuma legenda disponível. Iniciando transcrição automática via Whisper...")
            }

            await AppLogger.shared.info("No YouTube captions available, falling back to Whisper", category: .transcript)

            // STEP 2: Download audio
            await MainActor.run {
                progress("📥 Fase 1/3: Baixando áudio do vídeo...")
            }

            let audioPath: URL
            do {
                audioPath = try await AudioDownloadService.shared.downloadAudio(
                    url: url,
                    progress: progress
                )
            } catch {
                await AppLogger.shared.error("Audio download failed: \(error.localizedDescription)", category: .transcript)
                throw error
            }

            // STEP 3: Transcribe with Whisper
            await MainActor.run {
                progress("🤖 Fase 2/3: Transcrevendo áudio (pode demorar alguns minutos)...")
            }

            let transcript: String
            do {
                // Convert language code to Whisper format
                let whisperLanguage: String? = {
                    switch language {
                    case "pt", "pt-BR", "pt-PT": return "portuguese"
                    case "es", "es-ES", "es-MX": return "spanish"
                    case "fr", "fr-FR", "fr-CA": return "french"
                    case "de", "de-DE": return "german"
                    case "it", "it-IT": return "italian"
                    case "ja", "ja-JP": return "japanese"
                    case "ko", "ko-KR": return "korean"
                    case "zh", "zh-CN", "zh-TW": return "chinese"
                    case "en", "en-US", "en-GB": return nil  // Auto-detect
                    default: return nil
                    }
                }()

                if timed {
                    transcript = try await WhisperService.shared.transcribeWithTimestamps(
                        audioPath: audioPath,
                        language: whisperLanguage,
                        modelSize: WhisperService.ModelSize.base,
                        progress: progress
                    )
                } else {
                    transcript = try await WhisperService.shared.transcribe(
                        audioPath: audioPath,
                        language: whisperLanguage,
                        modelSize: WhisperService.ModelSize.base,
                        progress: progress
                    )
                }
            } catch {
                // Cleanup on error
                try? FileManager.default.removeItem(at: audioPath)
                await AppLogger.shared.error("Whisper transcription failed: \(error.localizedDescription)", category: .transcript)
                throw error
            }

            // STEP 4: Cleanup temp file
            await MainActor.run {
                progress("🧹 Fase 3/3: Finalizando...")
            }

            try? FileManager.default.removeItem(at: audioPath)

            await AppLogger.shared.info("Transcript obtained via Whisper", category: .transcript, metadata: [
                "word_count": String(transcript.split(separator: " ").count)
            ])

            await MainActor.run { progress("✅ Transcrição concluída via Whisper!") }

            return (transcript, .whisperTranscription)

        } catch {
            // Other errors - propagate
            await AppLogger.shared.error("Transcript fetch failed: \(error.localizedDescription)", category: .transcript)
            throw error
        }
    }

    // MARK: - Private Methods

    /// Builds a priority list of languages to try, based on user preference and common fallbacks
    private func buildLanguagePriorityList(
        userPreferred: String,
        available: [String]
    ) -> [String] {
        // Build ordered priority list
        var priority: [String] = []

        // 1. User's preferred language (if available)
        if available.contains(userPreferred) {
            priority.append(userPreferred)
        }

        // 2. Portuguese variants
        let portugueseVariants = ["pt", "pt-BR", "pt-PT"]
        for variant in portugueseVariants {
            if available.contains(variant) && !priority.contains(variant) {
                priority.append(variant)
            }
        }

        // 3. English variants
        let englishVariants = ["en", "en-US", "en-GB", "en-AU"]
        for variant in englishVariants {
            if available.contains(variant) && !priority.contains(variant) {
                priority.append(variant)
            }
        }

        // 4. Spanish variants
        let spanishVariants = ["es", "es-ES", "es-MX"]
        for variant in spanishVariants {
            if available.contains(variant) && !priority.contains(variant) {
                priority.append(variant)
            }
        }

        // 5. French variants
        let frenchVariants = ["fr", "fr-FR", "fr-CA"]
        for variant in frenchVariants {
            if available.contains(variant) && !priority.contains(variant) {
                priority.append(variant)
            }
        }

        // 6. Any other available languages
        for lang in available {
            if !priority.contains(lang) {
                priority.append(lang)
            }
        }

        return priority
    }

    // 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
        }
    }

    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 configured Python executable
            process.executableURL = URL(fileURLWithPath: AppConfig.Executables.pythonPath)
            process.arguments = ["-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 for progress updates
            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) {
                        // Parse progress messages
                        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 - operação demorou mais de \(Int(timeout/60)) minutos")
                        }
                        continuation.resume(throwing: MCPError.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 any 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: MCPError.processError(errorString))
                    } else {
                        let outputData = await buffer.getData()
                        guard let output = String(data: outputData, encoding: .utf8) else {
                            continuation.resume(throwing: MCPError.invalidResponse)
                            return
                        }
                        continuation.resume(returning: output)
                    }
                }
            }

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

    private func runPython(script: String) async throws -> String {
        let process = Process()
        let outputPipe = Pipe()
        let errorPipe = Pipe()

        // Use configured Python executable
        process.executableURL = URL(fileURLWithPath: AppConfig.Executables.pythonPath)
        process.arguments = ["-c", script]
        process.standardOutput = outputPipe
        process.standardError = errorPipe

        try process.run()
        process.waitUntilExit()

        let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
        let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()

        if process.terminationStatus != 0 {
            let errorString = String(data: errorData, encoding: .utf8) ?? "Unknown error"
            throw MCPError.processError(errorString)
        }

        guard let output = String(data: outputData, encoding: .utf8) else {
            throw MCPError.invalidResponse
        }

        return output
    }
}

// MARK: - Response Types

struct VideoInfoResponse: Codable {
    let title: String?
    let author: String?
    let thumbnail_url: String?
    let error: String?

    var channelName: String {
        author ?? "Unknown"
    }
}

struct LanguageListResponse: Codable {
    struct LanguageInfo: Codable {
        let language: String
        let is_generated: Bool
        let is_translatable: Bool
    }

    let languages: [LanguageInfo]
    let error: String?
}

// MARK: - Transcript Source

enum TranscriptSource {
    case youtubeCaption
    case whisperTranscription
}

// MARK: - Errors

enum MCPError: LocalizedError {
    case processError(String)
    case invalidResponse
    case invalidURL
    case transcriptNotAvailable(String)
    case timeout
    case noTranscriptsAvailable(videoID: String)
    case allLanguagesFailed(available: [String], lastError: Error?)

    var errorDescription: String? {
        switch self {
        case .processError(let message):
            return "Python Error: \(message)"
        case .invalidResponse:
            return "Invalid response from Python script"
        case .invalidURL:
            return "Invalid YouTube URL"
        case .transcriptNotAvailable(let message):
            return message
        case .timeout:
            return "A operação excedeu o tempo limite. Tente novamente ou verifique sua conexão."
        case .noTranscriptsAvailable(let videoID):
            return """
            ❌ Nenhuma legenda disponível para este vídeo.

            Vídeo ID: \(videoID)

            Este vídeo não possui legendas ou transcrições no YouTube.
            Possíveis razões:
            • O criador desabilitou legendas
            • O vídeo é muito recente (legendas ainda sendo processadas)
            • Erro temporário do YouTube

            Aguarde algumas horas e tente novamente.
            """
        case .allLanguagesFailed(let available, let lastError):
            let langs = available.joined(separator: ", ")
            let errorDetail = lastError?.localizedDescription ?? "Unknown error"
            return """
            ⚠ Falha ao buscar transcrição em todos os idiomas disponíveis.

            Idiomas tentados: \(langs)

            Último erro: \(errorDetail)

            Tente novamente mais tarde.
            """
        }
    }
}

// MARK: - URL Helpers

extension MCPService {
    static func extractVideoID(from url: String) -> String? {
        let patterns = [
            "(?:youtube\\.com/watch\\?v=)([a-zA-Z0-9_-]{11})",
            "(?:youtu\\.be/)([a-zA-Z0-9_-]{11})",
            "(?:youtube\\.com/embed/)([a-zA-Z0-9_-]{11})",
            "(?:youtube\\.com/v/)([a-zA-Z0-9_-]{11})"
        ]

        for pattern in patterns {
            if let regex = try? NSRegularExpression(pattern: pattern),
               let match = regex.firstMatch(in: url, range: NSRange(url.startIndex..., in: url)),
               let range = Range(match.range(at: 1), in: url) {
                return String(url[range])
            }
        }

        return nil
    }
}


Now let me examine other important files for queue/batch processing patterns:


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

List all Swift files in project


     1→import Foundation
     2→import SwiftData
     3→
     4→// MARK: - Text Annotation Model
     5→
     6→struct TextAnnotation: Codable, Identifiable, Equatable {
     7→    let id: UUID
     8→    let startIndex: Int
     9→    let endIndex: Int
    10→    let type: AnnotationType
    11→    let color: String
    12→    let createdAt: Date
    13→
    14→    enum AnnotationType: String, Codable {
    15→        case highlight
    16→        case underline
    17→    }
    18→
    19→    init(startIndex: Int, endIndex: Int, type: AnnotationType, color: String = "yellow") {
    20→        self.id = UUID()
    21→        self.startIndex = startIndex
    22→        self.endIndex = endIndex
    23→        self.type = type
    24→        self.color = color
    25→        self.createdAt = Date()
    26→    }
    27→}
    28→
    29→// MARK: - Saved Video Model
    30→
    31→@Model
    32→final class SavedVideo {
    33→    var id: UUID
    34→    var youtubeURL: String
    35→    var videoID: String
    36→    var title: String
    37→    var channelName: String
    38→    var transcript: String
    39→    var language: String
    40→    var duration: String?
    41→    var thumbnailURL: String?
    42→    var savedAt: Date
    43→    var isFavorite: Bool
    44→
    45→    // NOVO: Campo opcional para anotações (migração segura)
    46→    var annotationsData: Data?
    47→
    48→    // Sprint 4: Notas pessoais e AI Summary
    49→    var notes: String = ""
    50→    var aiSummary: String?
    51→    var aiSummaryGeneratedAt: Date?
    52→
    53→    // AI Formatting
    54→    var formattedTranscript: String?
    55→    var formattedAt: Date?
    56→    var formattingMode: String?
    57→
    58→    // Transcript Source (YouTube captions vs Whisper transcription)
    59→    var transcriptSource: String? // "youtube" or "whisper"
    60→
    61→    // Computed property para acessar anotações
    62→    var annotations: [TextAnnotation] {
    63→        get {
    64→            guard let data = annotationsData else { return [] }
    65→            return (try? JSONDecoder().decode([TextAnnotation].self, from: data)) ?? []
    66→        }
    67→        set {
    68→            annotationsData = try? JSONEncoder().encode(newValue)
    69→        }
    70→    }
    71→
    72→    init(
    73→        youtubeURL: String,
    74→        videoID: String,
    75→        title: String,
    76→        channelName: String,
    77→        transcript: String,
    78→        language: String = "en",
    79→        duration: String? = nil,
    80→        thumbnailURL: String? = nil,
    81→        isFavorite: Bool = false
    82→    ) {
    83→        self.id = UUID()
    84→        self.youtubeURL = youtubeURL
    85→        self.videoID = videoID
    86→        self.title = title
    87→        self.channelName = channelName
    88→        self.transcript = transcript
    89→        self.language = language
    90→        self.duration = duration
    91→        self.thumbnailURL = thumbnailURL
    92→        self.savedAt = Date()
    93→        self.isFavorite = isFavorite
    94→        self.annotationsData = nil
    95→    }
    96→
    97→    // MARK: - Annotation Helpers
    98→
    99→    func addAnnotation(_ annotation: TextAnnotation) {
   100→        var current = annotations

<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
import SwiftData

// MARK: - Text Annotation Model

struct TextAnnotation: Codable, Identifiable, Equatable {
    let id: UUID
    let startIndex: Int
    let endIndex: Int
    let type: AnnotationType
    let color: String
    let createdAt: Date

    enum AnnotationType: String, Codable {
        case highlight
        case underline
    }

    init(startIndex: Int, endIndex: Int, type: AnnotationType, color: String = "yellow") {
        self.id = UUID()
        self.startIndex = startIndex
        self.endIndex = endIndex
        self.type = type
        self.color = color
        self.createdAt = Date()
    }
}

// MARK: - Saved Video Model

@Model
final class SavedVideo {
    var id: UUID
    var youtubeURL: String
    var videoID: String
    var title: String
    var channelName: String
    var transcript: String
    var language: String
    var duration: String?
    var thumbnailURL: String?
    var savedAt: Date
    var isFavorite: Bool

    // NOVO: Campo opcional para anotações (migração segura)
    var annotationsData: Data?

    // Sprint 4: Notas pessoais e AI Summary
    var notes: String = ""
    var aiSummary: String?
    var aiSummaryGeneratedAt: Date?

    // AI Formatting
    var formattedTranscript: String?
    var formattedAt: Date?
    var formattingMode: String?

    // Transcript Source (YouTube captions vs Whisper transcription)
    var transcriptSource: String? // "youtube" or "whisper"

    // Computed property para acessar anotações
    var annotations: [TextAnnotation] {
        get {
            guard let data = annotationsData else { return [] }
            return (try? JSONDecoder().decode([TextAnnotation].self, from: data)) ?? []
        }
        set {
            annotationsData = try? JSONEncoder().encode(newValue)
        }
    }

    init(
        youtubeURL: String,
        videoID: String,
        title: String,
        channelName: String,
        transcript: String,
        language: String = "en",
        duration: String? = nil,
        thumbnailURL: String? = nil,
        isFavorite: Bool = false
    ) {
        self.id = UUID()
        self.youtubeURL = youtubeURL
        self.videoID = videoID
        self.title = title
        self.channelName = channelName
        self.transcript = transcript
        self.language = language
        self.duration = duration
        self.thumbnailURL = thumbnailURL
        self.savedAt = Date()
        self.isFavorite = isFavorite
        self.annotationsData = nil
    }

    // MARK: - Annotation Helpers

    func addAnnotation(_ annotation: TextAnnotation) {
        var current = annotations


Let me continue reading SavedVideo and look at the View layers:


   100→        var current = annotations
   101→        // Remove sobreposições do mesmo tipo
   102→        current.removeAll { existing in
   103→            existing.type == annotation.type &&
   104→            !(existing.endIndex < annotation.startIndex || existing.startIndex > annotation.endIndex)
   105→        }
   106→        current.append(annotation)
   107→        annotations = current
   108→    }
   109→
   110→    func removeAnnotation(at index: Int) {
   111→        var current = annotations
   112→        guard index < current.count else { return }
   113→        current.remove(at: index)
   114→        annotations = current
   115→    }
   116→
   117→    func clearAnnotations() {
   118→        annotations = []
   119→    }
   120→
   121→    func annotationsIn(range: Range<Int>) -> [TextAnnotation] {
   122→        annotations.filter { $0.startIndex < range.upperBound && $0.endIndex > range.lowerBound }
   123→    }
   124→
   125→    // MARK: - Formatting Helpers
   126→
   127→    func clearFormatting() {
   128→        formattedTranscript = nil
   129→        formattedAt = nil
   130→        formattingMode = nil
   131→    }
   132→
   133→    var hasFormattedTranscript: Bool {
   134→        formattedTranscript != nil
   135→    }
   136→
   137→    /// Returns the display transcript (formatted if available, otherwise original)
   138→    var displayTranscript: String {
   139→        formattedTranscript ?? transcript
   140→    }
   141→
   142→    // MARK: - Transcript Source Helpers
   143→
   144→    var sourceIcon: String {
   145→        switch transcriptSource {
   146→        case "whisper":
   147→            return "waveform"
   148→        case "youtube":
   149→            return "text.bubble"
   150→        default:
   151→            return "doc.text"
   152→        }
   153→    }
   154→
   155→    var sourceLabel: String {
   156→        switch transcriptSource {
   157→        case "whisper":
   158→            return "Transcrição por Áudio (Whisper)"
   159→        case "youtube":
   160→            return "Legendas do YouTube"
   161→        default:
   162→            return "Origem Desconhecida"
   163→        }
   164→    }
   165→
   166→    var sourceColor: String {
   167→        switch transcriptSource {
   168→        case "whisper":
   169→            return "purple"
   170→        case "youtube":
   171→            return "blue"
   172→        default:
   173→            return "gray"
   174→        }
   175→    }
   176→}
   177→
   178→

<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>

        var current = annotations
        // Remove sobreposições do mesmo tipo
        current.removeAll { existing in
            existing.type == annotation.type &&
            !(existing.endIndex < annotation.startIndex || existing.startIndex > annotation.endIndex)
        }
        current.append(annotation)
        annotations = current
    }

    func removeAnnotation(at index: Int) {
        var current = annotations
        guard index < current.count else { return }
        current.remove(at: index)
        annotations = current
    }

    func clearAnnotations() {
        annotations = []
    }

    func annotationsIn(range: Range<Int>) -> [TextAnnotation] {
        annotations.filter { $0.startIndex < range.upperBound && $0.endIndex > range.lowerBound }
    }

    // MARK: - Formatting Helpers

    func clearFormatting() {
        formattedTranscript = nil
        formattedAt = nil
        formattingMode = nil
    }

    var hasFormattedTranscript: Bool {
        formattedTranscript != nil
    }

    /// Returns the display transcript (formatted if available, otherwise original)
    var displayTranscript: String {
        formattedTranscript ?? transcript
    }

    // MARK: - Transcript Source Helpers

    var sourceIcon: String {
        switch transcriptSource {
        case "whisper":
            return "waveform"
        case "youtube":
            return "text.bubble"
        default:
            return "doc.text"
        }
    }

    var sourceLabel: String {
        switch transcriptSource {
        case "whisper":
            return "Transcrição por Áudio (Whisper)"
        case "youtube":
            return "Legendas do YouTube"
        default:
            return "Origem Desconhecida"
        }
    }

    var sourceColor: String {
        switch transcriptSource {
        case "whisper":
            return "purple"
        case "youtube":
            return "blue"
        default:
            return "gray"
        }
    }
}


     1→import SwiftUI
     2→import SwiftData
     3→import UniformTypeIdentifiers
     4→
     5→// MARK: - Transcript Input View
     6→
     7→struct TranscriptInputView: View {
     8→    @Environment(\.modelContext) private var modelContext
     9→
    10→    // User preferences
    11→    @AppStorage("defaultLanguage") private var defaultLanguage = "en"
    12→    @AppStorage("includeTimestamps") private var defaultTimestamps = false
    13→    @AppStorage("autoCheckClipboard") private var autoCheckClipboard = true
    14→
    15→    // ViewModel
    16→    @StateObject private var viewModel = TranscriptInputViewModel()
    17→
    18→    // Focus state
    19→    @FocusState private var isURLFieldFocused: Bool
    20→
    21→    // Callback
    22→    let onSave: (SavedVideo) -> Void
    23→
    24→    var body: some View {
    25→        VStack(spacing: 0) {
    26→            // Header
    27→            headerView
    28→
    29→            Divider()
    30→
    31→            // Clipboard Banner
    32→            if let clipURL = viewModel.clipboardURL, viewModel.youtubeURL.isEmpty {
    33→                clipboardBanner(url: clipURL)
    34→            }
    35→
    36→            // Form
    37→            formView
    38→
    39→            Divider()
    40→
    41→            // Log Console
    42→            if !viewModel.logs.isEmpty || viewModel.isLoading {
    43→                logConsoleView
    44→            }
    45→
    46→            Divider()
    47→
    48→            // Actions
    49→            actionsView
    50→        }
    51→        .frame(maxWidth: .infinity, maxHeight: .infinity)
    52→        .onAppear {
    53→            viewModel.initialize(
    54→                defaultLanguage: defaultLanguage,
    55→                defaultTimestamps: defaultTimestamps,
    56→                autoCheckClipboard: autoCheckClipboard
    57→            )
    58→            isURLFieldFocused = true
    59→        }
    60→        .onDrop(of: [.url, .plainText], isTargeted: $viewModel.isDropTargeted) { providers in
    61→            viewModel.handleDrop(providers: providers)
    62→        }
    63→        .overlay {
    64→            if viewModel.isDropTargeted {
    65→                dropOverlay
    66→            }
    67→        }
    68→        .overlay(alignment: .top) {
    69→            if viewModel.showingLogsCopied {
    70→                LogsCopiedToast()
    71→                    .transition(.move(edge: .top).combined(with: .opacity))
    72→            }
    73→        }
    74→    }
    75→
    76→    // MARK: - Header View
    77→
    78→    private var headerView: some View {
    79→        HStack {
    80→            Image(systemName: "play.rectangle.fill")

<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 SwiftUI
import SwiftData
import UniformTypeIdentifiers

// MARK: - Transcript Input View

struct TranscriptInputView: View {
    @Environment(\.modelContext) private var modelContext

    // User preferences
    @AppStorage("defaultLanguage") private var defaultLanguage = "en"
    @AppStorage("includeTimestamps") private var defaultTimestamps = false
    @AppStorage("autoCheckClipboard") private var autoCheckClipboard = true

    // ViewModel
    @StateObject private var viewModel = TranscriptInputViewModel()

    // Focus state
    @FocusState private var isURLFieldFocused: Bool

    // Callback
    let onSave: (SavedVideo) -> Void

    var body: some View {
        VStack(spacing: 0) {
            // Header
            headerView

            Divider()

            // Clipboard Banner
            if let clipURL = viewModel.clipboardURL, viewModel.youtubeURL.isEmpty {
                clipboardBanner(url: clipURL)
            }

            // Form
            formView

            Divider()

            // Log Console
            if !viewModel.logs.isEmpty || viewModel.isLoading {
                logConsoleView
            }

            Divider()

            // Actions
            actionsView
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .onAppear {
            viewModel.initialize(
                defaultLanguage: defaultLanguage,
                defaultTimestamps: defaultTimestamps,
                autoCheckClipboard: autoCheckClipboard
            )
            isURLFieldFocused = true
        }
        .onDrop(of: [.url, .plainText], isTargeted: $viewModel.isDropTargeted) { providers in
            viewModel.handleDrop(providers: providers)
        }
        .overlay {
            if viewModel.isDropTargeted {
                dropOverlay
            }
        }
        .overlay(alignment: .top) {
            if viewModel.showingLogsCopied {
                LogsCopiedToast()
                    .transition(.move(edge: .top).combined(with: .opacity))
            }
        }
    }

    // MARK: - Header View

    private var headerView: some View {
        HStack {
            Image(systemName: "play.rectangle.fill")


Let me check the README for project overview and look at the TranscriptImporter:


     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