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.

Marker rules (two tiers):

  • First chapter must be at 0:00
  • Each chapter at least 10 seconds long (gap between consecutive starts)
  • Persisted (hosted key-moments): minimum MIN_PERSISTED_CHAPTERS (2)
  • YouTube description push (#push_chapters): minimum MIN_CHAPTERS (3),
    timestamps in chronological order in the description

rubocop:disable Metrics/ClassLength

Constant Summary collapse

MIN_CHAPTERS =

YouTube's description-chapter minimum — enforced only when PUSHING to
YouTube (see #push_chapters).

3
MIN_PERSISTED_CHAPTERS =

Hosted persistence floor. Chapters are hosted-first: Google's key-moments
schema (hasPart) is useful from two Clips, so short videos whose
transcripts only support two markers still get chapters on-site; the
stricter MIN_CHAPTERS applies only at YouTube push time.

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



47
48
49
50
51
# File 'app/services/youtube/chapter_service.rb', line 47

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.



45
46
47
# File 'app/services/youtube/chapter_service.rb', line 45

def logger
  @logger
end

#preview_failure_messageObject (readonly)

Returns the value of attribute preview_failure_message.



45
46
47
# File 'app/services/youtube/chapter_service.rb', line 45

def preview_failure_message
  @preview_failure_message
end

#yt_clientObject (readonly)

Returns the value of attribute yt_client.



45
46
47
# File 'app/services/youtube/chapter_service.rb', line 45

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



56
57
58
59
60
61
62
63
64
65
66
67
# File 'app/services/youtube/chapter_service.rb', line 56

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)
  @preview_failure_message = "No chapters passed the marker rules (minimum #{MIN_PERSISTED_CHAPTERS} 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)


123
124
125
126
127
128
129
130
131
132
# File 'app/services/youtube/chapter_service.rb', line 123

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)


101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'app/services/youtube/chapter_service.rb', line 101

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.



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'app/services/youtube/chapter_service.rb', line 78

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?

  # Drop markers at or past the video's end before persisting — the same
  # out-of-bounds rule the VideoChapter model enforces. Applied here so the
  # bulk paths (AI generation, YouTube-description pull) skip stray markers
  # instead of raising RecordInvalid mid-transaction, e.g. pulling chapters
  # authored against a longer cut than the encode we now serve. Guarded on a
  # known duration.
  duration_ms = video.duration_ms
  list = list.select { |c| c[:start_ms].to_i < duration_ms } if duration_ms.positive?
  raise ArgumentError, 'No chapters fall within the video duration.' 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