Class: YouTube::ChapterService

Inherits:
Object
  • Object
show all
Defined in:
app/services/youtube/chapter_service.rb

Overview

Generates YouTube-style video chapters from structured transcript paragraphs
using AssemblyAI LLM Gateway (semantic boundaries + titles), then pushes
timestamp lines to the YouTube video description.

YouTube chapter format requirements:

  • First chapter must be at 0:00
  • Minimum 3 chapters
  • Each chapter at least 10 seconds long (gap between consecutive starts)
  • Timestamps in chronological order in the description

rubocop:disable Metrics/ClassLength

Constant Summary collapse

MIN_CHAPTERS =

Minimum chapters.

3
MAX_CHAPTERS =

Hard cap for YouTube + LLM schema; prompt steers the model toward fewer (see build_semantic_prompt).

12
MIN_CHAPTER_DURATION_SECONDS =

Minimum chapter duration seconds.

10
SINGLE_PASS_MAX_PARAGRAPHS =

Single-pass threshold: transcripts at or under this many paragraphs are
chapterized in ONE LLM call (the whole transcript in context), which yields
more coherent, better-deduplicated chapters than the segment-and-merge
fallback. Sized above the observed max (~555 paragraphs ≈ ~60K tokens, well
under the LeMUR/Opus gateway context window); p99 is ~140 paragraphs.

600
PARAGRAPHS_PER_SEGMENT =

Chunk size for the multi-segment FALLBACK path only — used when a transcript
exceeds SINGLE_PASS_MAX_PARAGRAPHS (rare multi-hour videos).

48
PARAGRAPH_TEXT_MAX =

Paragraph text max.

400
SEMANTIC_TOOL_NAME =

Semantic tool name.

'youtube_semantic_chapters'
LLM_MAX_TOKENS =

Llm max tokens.

4096

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(account: nil, yt_client: nil) ⇒ ChapterService

Returns a new instance of ChapterService.



40
41
42
43
44
# File 'app/services/youtube/chapter_service.rb', line 40

def initialize(account: nil, yt_client: nil)
  @yt_client = yt_client || YouTube::ApiClient.new(account: )
  @logger = Rails.logger
  @preview_failure_message = nil
end

Instance Attribute Details

#loggerObject (readonly)

Returns the value of attribute logger.



38
39
40
# File 'app/services/youtube/chapter_service.rb', line 38

def logger
  @logger
end

#preview_failure_messageObject (readonly)

Returns the value of attribute preview_failure_message.



38
39
40
# File 'app/services/youtube/chapter_service.rb', line 38

def preview_failure_message
  @preview_failure_message
end

#yt_clientObject (readonly)

Returns the value of attribute yt_client.



38
39
40
# File 'app/services/youtube/chapter_service.rb', line 38

def yt_client
  @yt_client
end

Instance Method Details

#preview_chapters(video) ⇒ Array<Hash>

Generate chapters without pushing to YouTube (preview).

Parameters:

Returns:

  • (Array<Hash>)

    the generated chapters



49
50
51
52
53
54
55
56
57
58
59
60
# File 'app/services/youtube/chapter_service.rb', line 49

def preview_chapters(video)
  @preview_failure_message = nil
  paragraphs = video.structured_transcript_paragraphs
  if paragraphs.blank?
    @preview_failure_message = 'No transcript paragraphs in structured transcript.'
    return []
  end

  chapters = generate_chapters(paragraphs, video.duration_in_seconds)
  @preview_failure_message = 'No chapters passed YouTube rules (minimum three markers, at least ten seconds apart).' if chapters.empty? && @preview_failure_message.blank?
  chapters
end

#pull_chapters_from_description(video) ⇒ Array<VideoChapter>

Fetch the YouTube video description and replace local chapters with parsed ones.
Lines starting with H:MM:SS Title or M:SS Title are recognised.

Parameters:

Returns:

  • (Array<VideoChapter>)

    the persisted chapters after the pull

Raises:

  • (ArgumentError)


106
107
108
109
110
111
112
113
114
115
# File 'app/services/youtube/chapter_service.rb', line 106

def pull_chapters_from_description(video)
  validate!(video)

  yt_video = @yt_client.get_video(video.youtube_id, parts: 'snippet')
  description = yt_video&.snippet&.description.to_s
  parsed = parse_chapter_lines(description)
  raise ArgumentError, 'No chapter lines found in the YouTube video description.' if parsed.empty?

  replace_chapters!(video, parsed)
end

#push_chapters(video) ⇒ Integer

Push the persisted VideoChapter rows for a video to its YouTube description.

Parameters:

Returns:

  • (Integer)

    number of chapters pushed

Raises:

  • (ArgumentError)


84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'app/services/youtube/chapter_service.rb', line 84

def push_chapters(video)
  validate!(video)

  records = video.video_chapters.reload.to_a
  raise ArgumentError, 'No chapters to push. Generate chapters or pull them from YouTube first.' if records.empty?
  raise ArgumentError, "YouTube needs at least #{MIN_CHAPTERS} chapters; this video has #{records.length}." if records.length < MIN_CHAPTERS
  raise ArgumentError, 'First chapter must start at 0:00.' if records.first.start_ms.to_i != 0

  records.each_cons(2) do |a, b|
    gap_ms = b.start_ms.to_i - a.start_ms.to_i
    raise ArgumentError, "Chapters at #{a.timestamp_hms} and #{b.timestamp_hms} are less than #{MIN_CHAPTER_DURATION_SECONDS} seconds apart." if gap_ms < MIN_CHAPTER_DURATION_SECONDS * 1000
  end

  list = records.map { |r| { timestamp: r.timestamp_hms, title: r.title } }
  push_chapters_to_description(video, list)
  records.length
end

#replace_chapters!(video, chapters) ⇒ Array<VideoChapter>

Wipe and replace the persisted VideoChapter rows for a video.
Accepts hashes with :title plus either :start_ms (Integer) or
:timestamp (an H:MM:SS / M:SS / bare-seconds string). Raises
rather than silently wiping when the input is empty or all rows are
missing the required keys.

Parameters:

  • video (Video)
  • chapters (Array<Hash>)

    each with :title and either :start_ms or :timestamp.

Returns:

Raises:

  • (ArgumentError)

    when the normalized list is empty.



71
72
73
74
75
76
77
78
79
# File 'app/services/youtube/chapter_service.rb', line 71

def replace_chapters!(video, chapters)
  list = Array(chapters).filter_map { |c| normalize_chapter_hash_for_replace(c) }
  raise ArgumentError, 'replace_chapters! requires at least one chapter with :title and :start_ms or :timestamp.' if list.empty?

  VideoChapter.transaction do
    video.video_chapters.destroy_all
    list.map { |c| video.video_chapters.create!(start_ms: c[:start_ms], title: c[:title]) }
  end
end