Skip to content

自建 YouTube 字幕问答系统 ​

客户端调用说明已独立到 java-openai 章节:SupadataClient 获取视频字幕。本篇保留应用接口、缓存和业务流程。

由于直接使用 Google Gemini YouTube 接口存在较高的延迟问题,因此本文介绍了一套自建 YouTube 视频问答系统的方案,通过调用第三方 supadata API 获取视频字幕,并将字幕进行存储与展示,实现视频问答功能。下面将从获取字幕、Java 代码实现、业务流程解析以及已知问题几个方面进行介绍。


1. 获取字幕 ​

系统通过调用 supadata API 来获取 YouTube 视频的字幕,API 调用方式如下:

supadata API 调用示例 ​

http
get https://api.supadata.ai/v1/youtube/transcript?videoId=kIo2BAubO6k

成功返回时的 JSON 格式如下:

json
{
  "lang": "zh",
  "availableLangs": ["zh"],
  "content": [
    {
      "lang": "zh",
      "text": "去持有某一家公司",
      "offset": 1115600,
      "duration": 1266
    },
    {
      "lang": "zh",
      "text": "或者去人为的干预这个公司的决策",
      "offset": 1116866,
      "duration": 2200
    }
  ]
}

返回结果中,content 数组中的每个对象都包含了字幕的文本内容、字幕对应的起始时间 (offset) 以及持续时间 (duration)。


2. Java 代码实现 ​

2.1 YoutubeVideoSubtitleService 类 ​

该类主要负责获取视频字幕,并将字幕文本存入数据库缓存。代码中使用了线程锁避免并发问题,同时支持两种获取方式:

  1. 通过 supadata API 获取字幕
  2. 如果获取不到,则调用备用接口 transcriptWithGemini (目前尚未实现)

完整代码如下:

java
package nexus.io.llm.service;

import java.util.List;
import java.util.concurrent.locks.Lock;

import com.google.common.util.concurrent.Striped;
import nexus.io.db.activerecord.Db;
import nexus.io.db.activerecord.Row;
import nexus.io.gemini.GeminiClient;
import nexus.io.gemini.GoogleGeminiModels;
import nexus.io.kit.PgObjectUtils;
import nexus.io.llm.consts.AgentTableNames;
import nexus.io.model.http.response.ResponseVo;
import nexus.io.supadata.SubTitleContent;
import nexus.io.supadata.SubTitleResponse;
import nexus.io.supadata.SupadataClient;
import nexus.io.tio.utils.hutool.StrUtil;
import nexus.io.tio.utils.json.FastJson2Utils;
import nexus.io.tio.utils.snowflake.SnowflakeIdUtils;
import nexus.io.tio.utils.video.VideoTimeUtils;

public class YoutubeVideoSubtitleService {
  public static final Striped<Lock> locks = Striped.lock(1024);

  public String get(String videoId) {
    String url = "https://www.youtube.com/watch?v=" + videoId;
    if (StrUtil.isBlank(videoId)) {
      return null;
    }
    Lock lock = locks.get(videoId);
    lock.lock();
    try {
      String sql = "select text_subtitle from %s where video_id=?";
      sql = String.format(sql, AgentTableNames.youtube_video_subtitle);
      String textSubTitle = Db.queryStr(sql, videoId);
      if (textSubTitle != null) {
        return textSubTitle;
      }
      ResponseVo responseVo = SupadataClient.get(videoId);
      SubTitleResponse subTitle = null;
      if (responseVo.isOk()) {
        subTitle = FastJson2Utils.parse(responseVo.getBodyString(), SubTitleResponse.class);
        List<SubTitleContent> content = subTitle.getContent();
        if (content == null) {
          return null;
        }
        StringBuffer stringBuffer = new StringBuffer();
        for (SubTitleContent subTitleContent : content) {
          long offset = subTitleContent.getOffset();
          long duration = subTitleContent.getDuration();
          // 计算结束时间 = 开始时间 + 持续时间
          long endTime = offset + duration;
          String startStr = VideoTimeUtils.formatTime(offset);
          String endStr = VideoTimeUtils.formatTime(endTime);
          String text = subTitleContent.getText();
          stringBuffer.append(startStr).append("-").append(endStr).append(" ").append(text).append("  \r\n");
        }
        textSubTitle = stringBuffer.toString();
      } else {
        textSubTitle = this.transcriptWithGemini(videoId, url);
      }
      if (textSubTitle != null) {
        Row row = Row.by("id", SnowflakeIdUtils.id()).set("video_id", videoId).set("text_subtitle", textSubTitle)
            //
            .set("supadata_subtitle", PgObjectUtils.json(responseVo.getBodyString()));
        Db.save(AgentTableNames.youtube_video_subtitle, row);
      }
      return textSubTitle;
    } finally {
      lock.unlock();
    }
  }

  public String transcriptWithGemini(String url, String videoId) {
    Lock lock = locks.get(videoId);
    lock.lock();
    try {
      String sql = "select text_subtitle from %s where video_id=?";
      sql = String.format(sql, AgentTableNames.youtube_video_subtitle);
      String textSubTitle = Db.queryStr(sql, videoId);
      if (textSubTitle != null) {
        return textSubTitle;
      }
      textSubTitle = transcriptWithGemini(url);
      if (textSubTitle == null) {
        return null;
      }
      Row row = Row.by("id", SnowflakeIdUtils.id()).set("video_id", videoId).set("text_subtitle", textSubTitle);
      Db.save(AgentTableNames.youtube_video_subtitle, row);
      return textSubTitle;
    } finally {
      lock.unlock();
    }
  }

  private String transcriptWithGemini(String url) {
    String model = GoogleGeminiModels.GEMINI_2_5_FLASH_PREVIEW_04_17;
    String userPrompt = "Extract video subtitles, output format [hh:mm:ss-hh:mm:ss] subtitle";
    String text = GeminiClient.parseYoutubeSubtitle(model, url, userPrompt);
    return text;
  }

}

业务流程说明

  • 线程锁保护: 使用 Guava 的 Striped<Lock> 实现对每个 videoId 级别的并发控制,确保同一视频在处理时不会出现并发问题。
  • 缓存处理: 首先从数据库中查询缓存的字幕,如果存在则直接返回,避免重复调用 API。
  • 调用 supadata API: 若缓存不存在,则通过 SupadataClient.get(videoId) 请求第三方接口获取字幕数据。
  • 字幕处理: 遍历返回的 content 数组,将每段字幕的开始时间、结束时间及文本进行格式化,并拼接成最终的字幕文本。
  • 存储数据: 将最终的字幕文本以及原始接口返回的数据存入数据库,便于后续直接读取。
  • 备用方案: 若 supadata 获取不到字幕,则调用 transcriptWithGemini 方法(目前未实现)尝试通过其他方式获取字幕。

2.2 YoutubeService 类 ​

该类主要负责处理前端请求,并将获取到的字幕内容通过 SSE(Server-Sent Events)实时发送到客户端,同时更新聊天记录。完整代码如下:

java
package nexus.io.llm.service;

import java.util.List;

import com.jfinal.kit.Kv;
import nexus.io.jfinal.aop.Aop;
import nexus.io.llm.consts.AiChatEventName;
import nexus.io.openai.chat.ChatMessage;
import nexus.io.openai.chat.ChatMessageArgs;
import nexus.io.tio.core.ChannelContext;
import nexus.io.tio.core.Tio;
import nexus.io.http.common.sse.SsePacket;
import nexus.io.tio.utils.json.JsonUtils;
import nexus.io.tio.utils.youtube.YouTubeIdUtil;

public class YoutubeService {

  private YoutubeVideoSubtitleService youtubeVideoSubtitleService = Aop.get(YoutubeVideoSubtitleService.class);

  public void youtube(ChannelContext channelContext, ChatMessageArgs chatSendArgs, List<ChatMessage> historyMessage) {
    String message = null;
    if (channelContext != null) {
      if (chatSendArgs != null && chatSendArgs.getUrl() != null) {
        String url = chatSendArgs.getUrl();
        message = "First, let me get the YouTube video sub title. It will take a few minutes " + url + ".  ";

        Kv by = Kv.by("content", message);
        SsePacket ssePacket = new SsePacket(AiChatEventName.reasoning, JsonUtils.toJson(by));
        Tio.send(channelContext, ssePacket);

        String videoId = YouTubeIdUtil.extractVideoId(url);
        String subTitle = youtubeVideoSubtitleService.get(videoId);
        if (subTitle == null) {
          message = "Sorry, No transcript is available for this video, let me downlaod video.  It will take a few minutes.  ";
          by = Kv.by("content", message);
          ssePacket = new SsePacket(AiChatEventName.reasoning, JsonUtils.toJson(by));
          Tio.send(channelContext, ssePacket);

          subTitle = youtubeVideoSubtitleService.transcriptWithGemini(url, videoId);
        }

        if (subTitle != null) {
          by = Kv.by("content", subTitle);
          ssePacket = new SsePacket(AiChatEventName.reasoning, JsonUtils.toJson(by));
          Tio.send(channelContext, ssePacket);

          historyMessage.add(0, new ChatMessage("user", subTitle));
        } else {
          message = "Sorry, No transcript is available for this video, please try again later.";
          by = Kv.by("content", message);
          ssePacket = new SsePacket(AiChatEventName.reasoning, JsonUtils.toJson(by));
          Tio.send(channelContext, ssePacket);
        }

      } else {
        message = "First, let me review the YouTube video. It will take a few minutes .";
        Kv by = Kv.by("content", message);
        SsePacket ssePacket = new SsePacket(AiChatEventName.reasoning, JsonUtils.toJson(by));
        Tio.send(channelContext, ssePacket);
      }
    }
  }
}

业务流程说明

  • 请求处理: 当接收到前端请求时,先判断 chatSendArgs 是否包含视频 URL。
  • 初步反馈: 系统先通过 SSE 向客户端发送提示信息,告知正在获取视频字幕。
  • 视频 ID 提取: 通过工具类 YouTubeIdUtil.extractVideoId(url) 从 URL 中提取视频 ID。
  • 获取字幕: 调用 YoutubeVideoSubtitleService.get(videoId) 尝试获取缓存或新获取字幕;若为空则调用备用方案 transcriptWithGemini(url, videoId)。
  • 结果返回: 获取到字幕后,系统将字幕内容通过 SSE 发送到客户端,并更新聊天记录;如果字幕仍为空,则返回错误提示信息。

2.3 第二次问答重载 YouTube 字幕的代码 ​

在第二次问答时,系统会根据历史记录中的参数重新加载字幕信息。代码如下:

java
          String role = record.getStr("role");
          String content = record.getStr("content");
          String args = record.getString("args");
          if (args != null) {
            ChatMessageArgs historyArgs = JsonUtils.parse(args, ChatMessageArgs.class);

            String url = historyArgs.getUrl();
            if (StrUtil.isNotBlank(url)) {
              if (ApiChatSendType.youtube.equals(historyArgs.getType())) {
                String extractVideoId = YouTubeIdUtil.extractVideoId(url);
                if (extractVideoId != null) {
                  String subTitle = youtubeVideoSubtitleService.get(extractVideoId);
                  if (subTitle != null) {
                    historyMessage.add(new ChatMessage(role, subTitle));

                  }
                }
              }
            }
            String[] urls = historyArgs.getUrls();
            if (urls != null && urls.length > 0) {
              if (ApiChatSendType.general.equals(historyArgs.getType())) {
                String htmlContent = webPageService.readHtmlPage(urls);
                historyMessage.add(new ChatMessage(role, htmlContent));
              }
            }

            if (StrUtil.isNotBlank(content)) {
              historyMessage.add(new ChatMessage(role, content));
            }
          } else {
            historyMessage.add(new ChatMessage(role, content));
          }

业务流程说明

  • 参数解析: 根据数据库中存储的历史记录,解析出角色、内容和参数(args)。
  • 视频 URL 处理: 若参数中包含 URL,则根据 URL 判断消息类型,如果是 YouTube 类型,则重新提取视频 ID,并调用 get 方法加载字幕。
  • 通用消息处理: 若参数中包含多个 URL,并且类型为通用类型,则调用 webPageService.readHtmlPage(urls) 获取网页内容并添加到聊天记录中。
  • 聊天记录更新: 最后根据是否存在 content,更新聊天历史记录,保证问答过程中的连续性。

3. 业务流程总结 ​

整个系统的业务流程可以总结为以下几个步骤:

  1. 视频字幕获取:

    • 根据视频 URL 提取视频 ID。
    • 首先尝试从数据库缓存中获取字幕内容,避免重复请求。
    • 若缓存不存在,则通过调用 supadata API 获取字幕,并将字幕内容格式化为带有时间戳的文本。
    • 将获取到的字幕内容及原始返回数据存入数据库。
  2. 前端交互:

    • 使用 SSE(Server-Sent Events)方式向客户端发送实时处理状态和最终结果。
    • 在首次请求和后续重载问答时,均会将字幕内容加入聊天记录,实现问答历史追踪。
  3. 备用字幕获取:

    • 当 supadata API 无法返回字幕时,系统提供了备用方法 transcriptWithGemini(目前尚未实现),以期支持其他方式获取字幕。

4. 已知问题 ​

目前存在一个已知问题:

  • 部分视频的第三方 URL 无法返回字幕
    有些视频由于版权或其他原因,调用第三方接口时可能无法获取到字幕。对此,系统在获取不到字幕时会反馈错误信息,提示用户稍后再试或者尝试其他方案。

5. 结论 ​

本文详细介绍了如何通过自建服务来实现 YouTube 视频问答系统,主要流程包括调用 supadata API 获取字幕、格式化与存储字幕内容以及通过 SSE 实时返回给客户端。同时也对代码中的业务流程做了详细说明,并指出了已知的第三方字幕获取问题。未来可进一步完善备用字幕接口 transcriptWithGemini,提高对无法返回字幕视频的支持能力。

MIT Licensed