# All files / src/app/api/proxy/subtitle route.ts

0% Statements 0/50
0% Branches 0/30
0% Functions 0/3
0% Lines 0/47

Press n or j to go to the next uncovered block, b, p or k for the previous block.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122

     import { NextRequest, NextResponse } from 'next/server';
    import { hasAuth, requireAuthEnabled, validateUpstreamUrl } from '../proxyUtils';

    export const runtime = 'nodejs';
    export const dynamic = 'force-dynamic';

    /**
     * Subtitle proxy and converter API route.
     *
     * IMPORTANT: This API route requires a Node.js server runtime to proxy and convert subtitle files.
     * The 'force-dynamic' export above ensures this route runs at runtime.
     */

    // Convert SRT format to WebVTT format
    function srtToWebVtt(srtContent: string): string {
      // Add WebVTT header
      let webvtt = 'WEBVTT\n\n';

      // Split into subtitle blocks
      const blocks = srtContent.trim().split('\n\n');

      for (const block of blocks) {
        const lines = block.trim().split('\n');
        if (lines.length < 3) continue;

        // Skip the sequence number (first line)
        const timeLine = lines[1];
        const textLines = lines.slice(2);

        // Convert SRT timestamp format to WebVTT format
        // SRT: 00:01:24,667 --> 00:01:26,043
        // WebVTT: 00:01:24.667 --> 00:01:26.043
        const webvttTimeLine = timeLine.replace(/,/g, '.');

        // Add cue to WebVTT
        webvtt += `${webvttTimeLine}\n`;
        webvtt += `${textLines.join('\n')}\n\n`;
      }

      return webvtt;
    }

    export async function GET(request: NextRequest) {
      try {
        const url = request.nextUrl.searchParams.get('url');

        if (!url) {
          return NextResponse.json({ error: 'URL parameter is required' }, { status: 400 });
        }

        if (requireAuthEnabled() && !hasAuth(request)) {
          return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
        }

        // Allow same-origin /api/* paths (used for internal subtitle endpoints behind rewrites),
        // but block arbitrary relative URLs. External URLs must pass allowlist validation.
        let upstreamUrl: URL;
        if (url.startsWith('/')) {
          if (!url.startsWith('/api/')) {
            return NextResponse.json({ error: 'Only /api/* relative paths are allowed' }, { status: 400 });
          }
          upstreamUrl = new URL(`${request.nextUrl.protocol}//${request.nextUrl.host}${url}`);
        } else {
          const validated = validateUpstreamUrl(url);
          if (!validated.ok) {
            return NextResponse.json({ error: validated.error }, { status: validated.status });
          }
          upstreamUrl = validated.url;
        }

        // Fetch the original subtitle file
        const response = await fetch(upstreamUrl, {
          headers: {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
            // Forward authorization if needed
            ...(request.headers.get('cookie') ? { 'Cookie': request.headers.get('cookie')! } : {})}});

        if (!response.ok) {
          console.error('🎬 Failed to fetch subtitle:', response.status, response.statusText);
          return NextResponse.json({ error: 'Failed to fetch subtitle' }, { status: response.status });
        }

        const contentType = response.headers.get('content-type') || '';
        const content = await response.text();

        let finalContent = content;
        const finalContentType = 'text/vtt; charset=utf-8';

        // Convert SRT to WebVTT if needed
        if (contentType.includes('subrip') || contentType.includes('srt') ||
          content.includes('-->') && !content.startsWith('WEBVTT')) {
          finalContent = srtToWebVtt(content);
        } else if (contentType.includes('vtt') || content.startsWith('WEBVTT')) {
        } else {
          finalContent = srtToWebVtt(content);
        }

        // Return with proper headers for subtitle consumption
        return new NextResponse(finalContent, {
          headers: {
            'Content-Type': finalContentType,
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Methods': 'GET, OPTIONS',
            'Access-Control-Allow-Headers': 'Content-Type',
            'Cache-Control': 'public, max-age=3600'}});

      } catch (error) {
        console.error('🎬 Subtitle proxy error:', error);
        return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
      }
    }

    export async function OPTIONS() {
      return new NextResponse(null, {
        status: 200,
        headers: {
          'Access-Control-Allow-Origin': '*',
          'Access-Control-Allow-Methods': 'GET, OPTIONS',
          'Access-Control-Allow-Headers': 'Content-Type'}});
    }