Class: YouTube::ChapterService
- Inherits:
-
Object
- Object
- YouTube::ChapterService
- 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
-
#logger ⇒ Object
readonly
Returns the value of attribute logger.
-
#preview_failure_message ⇒ Object
readonly
Returns the value of attribute preview_failure_message.
-
#yt_client ⇒ Object
readonly
Returns the value of attribute yt_client.
Instance Method Summary collapse
-
#initialize(account: nil, yt_client: nil) ⇒ ChapterService
constructor
A new instance of ChapterService.
-
#preview_chapters(video) ⇒ Array<Hash>
Generate chapters without pushing to YouTube (preview).
-
#pull_chapters_from_description(video) ⇒ Array<VideoChapter>
Fetch the YouTube video description and replace local chapters with parsed ones.
-
#push_chapters(video) ⇒ Integer
Push the persisted VideoChapter rows for a video to its YouTube description.
-
#replace_chapters!(video, chapters) ⇒ Array<VideoChapter>
Wipe and replace the persisted VideoChapter rows for a video.
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: account) @logger = Rails.logger @preview_failure_message = nil end |
Instance Attribute Details
#logger ⇒ Object (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_message ⇒ Object (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 end |
#yt_client ⇒ Object (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).
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.
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.
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.} and #{b.} are less than #{MIN_CHAPTER_DURATION_SECONDS} seconds apart." if gap_ms < MIN_CHAPTER_DURATION_SECONDS * 1000 end list = records.map { |r| { timestamp: r., 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.
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 |