| @@ -84,6 +84,7 @@ class Tag < ApplicationRecord | |||||
| def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta) | def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta) | ||||
| def self.video = find_or_create_by_tag_name!('動画', category: :meta) | def self.video = find_or_create_by_tag_name!('動画', category: :meta) | ||||
| def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta) | def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta) | ||||
| def self.youtube = find_or_create_by_tag_name!('YouTube', category: :meta) | |||||
| def self.normalise_tags! tag_names, with_tagme: true, | def self.normalise_tags! tag_names, with_tagme: true, | ||||
| with_no_deerjikist: true, | with_no_deerjikist: true, | ||||
| @@ -0,0 +1,73 @@ | |||||
| require 'json' | |||||
| require 'net/http' | |||||
| require 'uri' | |||||
| module Youtube | |||||
| class ApiClient | |||||
| ENDPOINT = 'https://www.googleapis.com/youtube/v3' | |||||
| def initialize api_key: ENV.fetch('YOUTUBE_API_KEY') | |||||
| @api_key = api_key | |||||
| end | |||||
| def search_videos q:, published_after: nil, published_before: nil, page_token: nil | |||||
| get_json('/search', { | |||||
| part: 'snippet', | |||||
| type: 'video', | |||||
| q:, | |||||
| order: 'date', | |||||
| maxResults: 50, | |||||
| regionCode: 'JP', | |||||
| relevanceLanguage: 'ja', | |||||
| publishedAfter: published_after&.iso8601, | |||||
| publishedBefore: published_before&.iso8601, | |||||
| pageToken: page_token }.compact) | |||||
| end | |||||
| def videos ids | |||||
| return { 'items' => [] } if ids.empty? | |||||
| get_json('/videos', part: 'snippet,status,contentDetails', id: ids.join(',')) | |||||
| end | |||||
| def playlist_items playlist_id:, page_token: nil | |||||
| get_json('/playlistItems', { | |||||
| part: 'snippet,contentDetails,status', | |||||
| playlistId: playlist_id, | |||||
| maxResults: 50, | |||||
| pageToken: page_token }.compact) | |||||
| end | |||||
| def channel id: nil, handle: nil | |||||
| raise ArgumentError, 'id or handle is required' if id.present? == handle.present? | |||||
| params = { part: 'snippet,contentDetails' } | |||||
| params[:id] = id if id.present? | |||||
| params[:forHandle] = handle if handle.present? | |||||
| get_json('/channels', params) | |||||
| end | |||||
| private | |||||
| def get_json path, params | |||||
| uri = URI(ENDPOINT + path) | |||||
| uri.query = URI.encode_www_form(params.merge(key: @api_key)) | |||||
| response = Net::HTTP.start(uri.host, | |||||
| uri.port, | |||||
| use_ssl: true, | |||||
| open_timeout: 10, | |||||
| read_timeout: 30) do |http| | |||||
| http.get(uri) | |||||
| end | |||||
| unless response.is_a?(Net::HTTPSuccess) | |||||
| raise "YouTube API error: #{ response.code } #{ response.body }" | |||||
| end | |||||
| JSON.parse(response.body) | |||||
| end | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,168 @@ | |||||
| require 'open-uri' | |||||
| require 'set' | |||||
| require 'time' | |||||
| module Youtube | |||||
| class Sync | |||||
| def initialize client: ApiClient.new | |||||
| @client = client | |||||
| end | |||||
| def sync! | |||||
| video_ids = discover_video_ids | |||||
| return if video_ids.empty? | |||||
| video_ids.each_slice(50) do |ids| | |||||
| @client.videos(ids).fetch('items', []).each do |item| | |||||
| sync_video!(VideoItem.new(item)) | |||||
| end | |||||
| end | |||||
| end | |||||
| private | |||||
| def discover_video_ids | |||||
| ids = Set.new | |||||
| query_terms.each do |q| | |||||
| response = @client.search_videos(q:, published_after: sync_since) | |||||
| response.fetch('items', []).each do |item| | |||||
| video_id = item.dig('id', 'videoId') | |||||
| ids << video_id if video_id.present? | |||||
| end | |||||
| end | |||||
| playlist_ids.each do |playlist_id| | |||||
| each_playlist_item(playlist_id) do |item| | |||||
| video_id = item.dig('contentDetails', 'videoId') | |||||
| video_id ||= item.dig('snippet', 'resourceId', 'videoId') | |||||
| ids << video_id if video_id.present? | |||||
| end | |||||
| end | |||||
| ids.to_a | |||||
| end | |||||
| def sync_video! video | |||||
| post = Post.where('url REGEXP ?', youtube_url_regexp(video.id)).first | |||||
| original_created_from = video.published_at.change(sec: 0) | |||||
| original_created_before = original_created_from + 1.minute | |||||
| post_created = false | |||||
| post_changed = false | |||||
| if post | |||||
| post.assign_attributes(title: video.title, | |||||
| original_created_from:, | |||||
| original_created_before:, | |||||
| thumbnail_base: video.thumbnail_url) | |||||
| post_changed = post.changed? | |||||
| post.save! if post_changed | |||||
| attach_thumbnail_if_needed!(post, video.thumbnail_url) | |||||
| else | |||||
| post_created = true | |||||
| post = Post.create!( | |||||
| title: video.title, | |||||
| url: video.url, | |||||
| thumbnail_base: video.thumbnail_url, | |||||
| uploaded_user_id: nil, | |||||
| original_created_from:, | |||||
| original_created_before:) | |||||
| attach_thumbnail_if_needed!(post, video.thumbnail_url) | |||||
| sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.youtube.id, Tag.video.id]) | |||||
| end | |||||
| kept_tag_ids = post.tags.pluck(:id).to_set | |||||
| desired_tag_ids = kept_tag_ids.to_a | |||||
| deerjikist = Deerjikist.find_by(platform: :youtube, code: video.channel_id) | |||||
| if deerjikist | |||||
| desired_tag_ids.delete(Tag.no_deerjikist.id) | |||||
| desired_tag_ids << deerjikist.tag_id | |||||
| elsif post.tags.where(category: :deerjikist).none? | |||||
| desired_tag_ids << Tag.no_deerjikist.id | |||||
| end | |||||
| desired_tag_ids.uniq! | |||||
| sync_post_tags!(post, desired_tag_ids, current_tag_ids: kept_tag_ids) | |||||
| if post_created | |||||
| PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil) | |||||
| elsif post_changed || kept_tag_ids != desired_tag_ids.to_set | |||||
| PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil) | |||||
| PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) | |||||
| end | |||||
| end | |||||
| def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil | |||||
| current_tag_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set | |||||
| desired_tag_ids = desired_tag_ids.compact.to_set | |||||
| to_add = desired_tag_ids - current_tag_ids | |||||
| to_remove = current_tag_ids - desired_tag_ids | |||||
| Tag.where(id: to_add.to_a).find_each do |tag| | |||||
| begin | |||||
| PostTag.create!(post:, tag:) | |||||
| rescue ActiveRecord::RecordNotUnique | |||||
| ; | |||||
| end | |||||
| end | |||||
| PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt| | |||||
| pt.discard_by!(nil) | |||||
| end | |||||
| end | |||||
| def attach_thumbnail_if_needed! post, thumbnail_url | |||||
| return if post.thumbnail.attached? | |||||
| return if thumbnail_url.blank? | |||||
| post.thumbnail.attach( | |||||
| io: URI.open(thumbnail_url), | |||||
| filename: File.basename(URI.parse(thumbnail_url).path), | |||||
| content_type: 'image/jpeg') | |||||
| post.resized_thumbnail! | |||||
| end | |||||
| def youtube_url_regexp id | |||||
| escaped = Regexp.escape(id) | |||||
| "(youtube\\.com/watch\\?v=#{ escaped }|youtu\\.be/#{ escaped })([^A-Za-z0-9_-]|$)" | |||||
| end | |||||
| def query_terms = ['ぼざろクリーチャーシリーズ', '伊地知ニジカ', '伊地知虹鹿'] | |||||
| def playlist_ids | |||||
| ['PLrOch4zHkI5vu29b-f9umUQQ4tQkuWLPX', | |||||
| 'PLrOch4zHkI5vOK0RaytQq6PbucxQkkL0K', | |||||
| 'PLrOch4zHkI5tdwm9vSegiDQJOM-hgpcOC'] | |||||
| end | |||||
| def sync_since = 14.days.ago | |||||
| def each_playlist_item playlist_id | |||||
| page_token = nil | |||||
| loop do | |||||
| response = @client.playlist_items(playlist_id:, page_token:) | |||||
| response.fetch('items', []).each do |item| | |||||
| yield item | |||||
| end | |||||
| page_token = response['nextPageToken'] | |||||
| break if page_token.blank? | |||||
| end | |||||
| end | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,32 @@ | |||||
| require 'time' | |||||
| module Youtube | |||||
| class VideoItem | |||||
| attr_reader :id, :title, :channel_id, :published_at, :thumbnail_url, :raw_tags | |||||
| def initialize item | |||||
| snippet = item.fetch('snippet') | |||||
| @id = item.fetch('id') | |||||
| @title = snippet['title'] | |||||
| @channel_id = snippet['channelId'] | |||||
| @published_at = Time.iso8601(snippet['publishedAt']) | |||||
| @thumbnail_url = pick_thumbnail(snippet['thumbnails'] || { }) | |||||
| @raw_tags = snippet['tags'] || [] | |||||
| end | |||||
| def url = "https://www.youtube.com/watch?v=#{ @id }" | |||||
| private | |||||
| def pick_thumbnail thumbnails | |||||
| ['maxres', 'standard', 'high', 'medium', 'default'].each do |key| | |||||
| url = thumbnails.dig(key, 'url') | |||||
| return url if url.present? | |||||
| end | |||||
| nil | |||||
| end | |||||
| end | |||||
| end | |||||
| @@ -17,3 +17,11 @@ every 1.day, at: '0:00 am' do | |||||
| rake 'post_similarity:calc', environment: 'production' | rake 'post_similarity:calc', environment: 'production' | ||||
| rake 'tag_similarity:calc', environment: 'production' | rake 'tag_similarity:calc', environment: 'production' | ||||
| end | end | ||||
| every 1.day, at: '7:50 am' do | |||||
| rake 'nico:export', environment: 'production' | |||||
| end | |||||
| every :hour do | |||||
| rake 'post:sync', environment: 'production' | |||||
| end | |||||
| @@ -0,0 +1,6 @@ | |||||
| namespace :post do | |||||
| desc '投稿同期(ニコニコ以外)' | |||||
| task sync: :environment do | |||||
| Youtube::Sync.new.sync! | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,130 @@ | |||||
| require 'rails_helper' | |||||
| RSpec.describe Youtube::ApiClient do | |||||
| let(:api_key) { 'test-api-key' } | |||||
| let(:client) { described_class.new(api_key:) } | |||||
| describe '#search_videos' do | |||||
| it 'calls YouTube search API with expected params' do | |||||
| published_after = Time.zone.parse('2026-05-01 00:00:00') | |||||
| published_before = Time.zone.parse('2026-05-02 00:00:00') | |||||
| expect(client).to receive(:get_json).with( | |||||
| '/search', | |||||
| { | |||||
| part: 'snippet', | |||||
| type: 'video', | |||||
| q: 'ぼざろクリーチャー', | |||||
| order: 'date', | |||||
| maxResults: 50, | |||||
| regionCode: 'JP', | |||||
| relevanceLanguage: 'ja', | |||||
| publishedAfter: published_after.iso8601, | |||||
| publishedBefore: published_before.iso8601, | |||||
| pageToken: 'NEXT' | |||||
| } | |||||
| ).and_return({ 'items' => [] }) | |||||
| client.search_videos( | |||||
| q: 'ぼざろクリーチャー', | |||||
| published_after:, | |||||
| published_before:, | |||||
| page_token: 'NEXT' | |||||
| ) | |||||
| end | |||||
| it 'omits nil optional params' do | |||||
| expect(client).to receive(:get_json).with( | |||||
| '/search', | |||||
| hash_excluding(:publishedAfter, :publishedBefore, :pageToken) | |||||
| ).and_return({ 'items' => [] }) | |||||
| client.search_videos(q: 'ぼざろクリーチャー') | |||||
| end | |||||
| end | |||||
| describe '#videos' do | |||||
| it 'returns empty items when ids are empty' do | |||||
| expect(client).not_to receive(:get_json) | |||||
| expect(client.videos([])).to eq({ 'items' => [] }) | |||||
| end | |||||
| it 'calls videos API with comma separated ids' do | |||||
| expect(client).to receive(:get_json).with( | |||||
| '/videos', | |||||
| { | |||||
| part: 'snippet,status,contentDetails', | |||||
| id: 'video-1,video-2' | |||||
| } | |||||
| ).and_return({ 'items' => [] }) | |||||
| client.videos(['video-1', 'video-2']) | |||||
| end | |||||
| end | |||||
| describe '#playlist_items' do | |||||
| it 'calls playlistItems API with page token' do | |||||
| expect(client).to receive(:get_json).with( | |||||
| '/playlistItems', | |||||
| { | |||||
| part: 'snippet,contentDetails,status', | |||||
| playlistId: 'PL123', | |||||
| maxResults: 50, | |||||
| pageToken: 'NEXT' | |||||
| } | |||||
| ).and_return({ 'items' => [] }) | |||||
| client.playlist_items(playlist_id: 'PL123', page_token: 'NEXT') | |||||
| end | |||||
| it 'omits page token when nil' do | |||||
| expect(client).to receive(:get_json).with( | |||||
| '/playlistItems', | |||||
| { | |||||
| part: 'snippet,contentDetails,status', | |||||
| playlistId: 'PL123', | |||||
| maxResults: 50 | |||||
| } | |||||
| ).and_return({ 'items' => [] }) | |||||
| client.playlist_items(playlist_id: 'PL123') | |||||
| end | |||||
| end | |||||
| describe '#channel' do | |||||
| it 'calls channels API by id' do | |||||
| expect(client).to receive(:get_json).with( | |||||
| '/channels', | |||||
| { | |||||
| part: 'snippet,contentDetails', | |||||
| id: 'UC123' | |||||
| } | |||||
| ).and_return({ 'items' => [] }) | |||||
| client.channel(id: 'UC123') | |||||
| end | |||||
| it 'calls channels API by handle' do | |||||
| expect(client).to receive(:get_json).with( | |||||
| '/channels', | |||||
| { | |||||
| part: 'snippet,contentDetails', | |||||
| forHandle: '@some_handle' | |||||
| } | |||||
| ).and_return({ 'items' => [] }) | |||||
| client.channel(handle: '@some_handle') | |||||
| end | |||||
| it 'raises when neither id nor handle is given' do | |||||
| expect { client.channel }.to raise_error(ArgumentError, 'id or handle is required') | |||||
| end | |||||
| it 'raises when both id and handle are given' do | |||||
| expect do | |||||
| client.channel(id: 'UC123', handle: '@some_handle') | |||||
| end.to raise_error(ArgumentError, 'id or handle is required') | |||||
| end | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,310 @@ | |||||
| require 'rails_helper' | |||||
| RSpec.describe Youtube::Sync do | |||||
| let(:client) { instance_double(Youtube::ApiClient) } | |||||
| let(:sync) { described_class.new(client:) } | |||||
| before do | |||||
| allow(PostVersionRecorder).to receive(:record!) | |||||
| allow(PostVersionRecorder).to receive(:ensure_snapshot!) | |||||
| allow(sync).to receive(:attach_thumbnail_if_needed!) | |||||
| end | |||||
| describe '#sync!' do | |||||
| it 'returns without fetching video details when no video ids are discovered' do | |||||
| allow(sync).to receive(:query_terms).and_return([]) | |||||
| allow(sync).to receive(:playlist_ids).and_return([]) | |||||
| expect(client).not_to receive(:videos) | |||||
| sync.sync! | |||||
| end | |||||
| it 'discovers ids from search and all playlist pages' do | |||||
| allow(sync).to receive(:query_terms).and_return(['ぼざろクリーチャー']) | |||||
| allow(sync).to receive(:playlist_ids).and_return(['PL123']) | |||||
| allow(sync).to receive(:sync_since).and_return(Time.zone.parse('2026-05-01 00:00:00')) | |||||
| allow(client).to receive(:search_videos).with( | |||||
| q: 'ぼざろクリーチャー', | |||||
| published_after: Time.zone.parse('2026-05-01 00:00:00') | |||||
| ).and_return({ | |||||
| 'items' => [ | |||||
| { | |||||
| 'id' => { | |||||
| 'videoId' => 'search-video-1' | |||||
| } | |||||
| } | |||||
| ] | |||||
| }) | |||||
| allow(client).to receive(:playlist_items).with( | |||||
| playlist_id: 'PL123', | |||||
| page_token: nil | |||||
| ).and_return({ | |||||
| 'items' => [ | |||||
| { | |||||
| 'contentDetails' => { | |||||
| 'videoId' => 'playlist-video-1' | |||||
| } | |||||
| } | |||||
| ], | |||||
| 'nextPageToken' => 'NEXT' | |||||
| }) | |||||
| allow(client).to receive(:playlist_items).with( | |||||
| playlist_id: 'PL123', | |||||
| page_token: 'NEXT' | |||||
| ).and_return({ | |||||
| 'items' => [ | |||||
| { | |||||
| 'snippet' => { | |||||
| 'resourceId' => { | |||||
| 'videoId' => 'playlist-video-2' | |||||
| } | |||||
| } | |||||
| } | |||||
| ] | |||||
| }) | |||||
| expect(client).to receive(:videos).with( | |||||
| satisfy do |ids| | |||||
| ids.sort == ['playlist-video-1', 'playlist-video-2', 'search-video-1'] | |||||
| end | |||||
| ).and_return({ 'items' => [] }) | |||||
| sync.sync! | |||||
| end | |||||
| it 'creates a YouTube post with default tags and no_deerjikist when no deerjikist mapping exists' do | |||||
| Tag.tagme | |||||
| Tag.bot | |||||
| Tag.youtube | |||||
| Tag.video | |||||
| Tag.no_deerjikist | |||||
| allow(sync).to receive(:query_terms).and_return([]) | |||||
| allow(sync).to receive(:playlist_ids).and_return(['PL123']) | |||||
| allow(client).to receive(:playlist_items).with( | |||||
| playlist_id: 'PL123', | |||||
| page_token: nil | |||||
| ).and_return({ | |||||
| 'items' => [ | |||||
| { | |||||
| 'contentDetails' => { | |||||
| 'videoId' => 'video-1' | |||||
| } | |||||
| } | |||||
| ] | |||||
| }) | |||||
| allow(client).to receive(:videos).with(['video-1']).and_return({ | |||||
| 'items' => [ | |||||
| youtube_video_item( | |||||
| id: 'video-1', | |||||
| title: 'YouTube テスト動画', | |||||
| channel_id: 'UC_NO_MAPPING' | |||||
| ) | |||||
| ] | |||||
| }) | |||||
| expect do | |||||
| sync.sync! | |||||
| end.to change(Post, :count).by(1) | |||||
| post = Post.find_by!(url: 'https://www.youtube.com/watch?v=video-1') | |||||
| tag_ids = post.tags.pluck(:id) | |||||
| expect(post.title).to eq('YouTube テスト動画') | |||||
| expect(post.uploaded_user_id).to be_nil | |||||
| expect(post.original_created_from).to eq(Time.zone.parse('2026-05-01 12:34:00')) | |||||
| expect(post.original_created_before).to eq(Time.zone.parse('2026-05-01 12:35:00')) | |||||
| expect(tag_ids).to include(Tag.tagme.id) | |||||
| expect(tag_ids).to include(Tag.bot.id) | |||||
| expect(tag_ids).to include(Tag.youtube.id) | |||||
| expect(tag_ids).to include(Tag.video.id) | |||||
| expect(tag_ids).to include(Tag.no_deerjikist.id) | |||||
| expect(PostVersionRecorder).to have_received(:record!).with( | |||||
| post:, | |||||
| event_type: :create, | |||||
| created_by_user: nil | |||||
| ) | |||||
| end | |||||
| it 'uses deerjikist tag when channel id is mapped' do | |||||
| Tag.tagme | |||||
| Tag.bot | |||||
| Tag.youtube | |||||
| Tag.video | |||||
| Tag.no_deerjikist | |||||
| deerjikist_tag = Tag.find_or_create_by_tag_name!('テスト投稿者', category: :deerjikist) | |||||
| Deerjikist.create!( | |||||
| platform: 'youtube', | |||||
| code: 'UC_MAPPED', | |||||
| tag: deerjikist_tag | |||||
| ) | |||||
| allow(sync).to receive(:query_terms).and_return([]) | |||||
| allow(sync).to receive(:playlist_ids).and_return(['PL123']) | |||||
| allow(client).to receive(:playlist_items).with( | |||||
| playlist_id: 'PL123', | |||||
| page_token: nil | |||||
| ).and_return({ | |||||
| 'items' => [ | |||||
| { | |||||
| 'contentDetails' => { | |||||
| 'videoId' => 'video-1' | |||||
| } | |||||
| } | |||||
| ] | |||||
| }) | |||||
| allow(client).to receive(:videos).with(['video-1']).and_return({ | |||||
| 'items' => [ | |||||
| youtube_video_item( | |||||
| id: 'video-1', | |||||
| title: 'YouTube テスト動画', | |||||
| channel_id: 'UC_MAPPED' | |||||
| ) | |||||
| ] | |||||
| }) | |||||
| sync.sync! | |||||
| post = Post.find_by!(url: 'https://www.youtube.com/watch?v=video-1') | |||||
| tag_ids = post.tags.pluck(:id) | |||||
| expect(tag_ids).to include(deerjikist_tag.id) | |||||
| expect(tag_ids).not_to include(Tag.no_deerjikist.id) | |||||
| end | |||||
| it 'removes no_deerjikist when deerjikist mapping is added later' do | |||||
| Tag.no_deerjikist | |||||
| post = Post.create!( | |||||
| title: '旧タイトル', | |||||
| url: 'https://www.youtube.com/watch?v=video-1', | |||||
| uploaded_user_id: nil, | |||||
| original_created_from: Time.zone.parse('2026-05-01 00:00:00'), | |||||
| original_created_before: Time.zone.parse('2026-05-01 00:01:00') | |||||
| ) | |||||
| PostTag.create!(post:, tag: Tag.no_deerjikist) | |||||
| deerjikist_tag = Tag.find_or_create_by_tag_name!('後から判明した投稿者', category: :deerjikist) | |||||
| Deerjikist.create!( | |||||
| platform: 'youtube', | |||||
| code: 'UC_MAPPED_LATER', | |||||
| tag: deerjikist_tag | |||||
| ) | |||||
| allow(sync).to receive(:query_terms).and_return([]) | |||||
| allow(sync).to receive(:playlist_ids).and_return(['PL123']) | |||||
| allow(client).to receive(:playlist_items).with( | |||||
| playlist_id: 'PL123', | |||||
| page_token: nil | |||||
| ).and_return({ | |||||
| 'items' => [ | |||||
| { | |||||
| 'contentDetails' => { | |||||
| 'videoId' => 'video-1' | |||||
| } | |||||
| } | |||||
| ] | |||||
| }) | |||||
| allow(client).to receive(:videos).with(['video-1']).and_return({ | |||||
| 'items' => [ | |||||
| youtube_video_item( | |||||
| id: 'video-1', | |||||
| title: '新タイトル', | |||||
| channel_id: 'UC_MAPPED_LATER' | |||||
| ) | |||||
| ] | |||||
| }) | |||||
| sync.sync! | |||||
| post.reload | |||||
| tag_ids = post.tags.pluck(:id) | |||||
| expect(post.title).to eq('新タイトル') | |||||
| expect(tag_ids).to include(deerjikist_tag.id) | |||||
| expect(tag_ids).not_to include(Tag.no_deerjikist.id) | |||||
| expect(PostVersionRecorder).to have_received(:ensure_snapshot!).with( | |||||
| post, | |||||
| created_by_user: nil | |||||
| ) | |||||
| expect(PostVersionRecorder).to have_received(:record!).with( | |||||
| post:, | |||||
| event_type: :update, | |||||
| created_by_user: nil | |||||
| ) | |||||
| end | |||||
| it 'matches existing youtu.be URL and does not create duplicate post' do | |||||
| post = Post.create!( | |||||
| title: '旧タイトル', | |||||
| url: 'https://youtu.be/video-1', | |||||
| uploaded_user_id: nil, | |||||
| original_created_from: Time.zone.parse('2026-05-01 00:00:00'), | |||||
| original_created_before: Time.zone.parse('2026-05-01 00:01:00') | |||||
| ) | |||||
| allow(sync).to receive(:query_terms).and_return([]) | |||||
| allow(sync).to receive(:playlist_ids).and_return(['PL123']) | |||||
| allow(client).to receive(:playlist_items).with( | |||||
| playlist_id: 'PL123', | |||||
| page_token: nil | |||||
| ).and_return({ | |||||
| 'items' => [ | |||||
| { | |||||
| 'contentDetails' => { | |||||
| 'videoId' => 'video-1' | |||||
| } | |||||
| } | |||||
| ] | |||||
| }) | |||||
| allow(client).to receive(:videos).with(['video-1']).and_return({ | |||||
| 'items' => [ | |||||
| youtube_video_item( | |||||
| id: 'video-1', | |||||
| title: '新タイトル', | |||||
| channel_id: 'UC_NO_MAPPING' | |||||
| ) | |||||
| ] | |||||
| }) | |||||
| expect do | |||||
| sync.sync! | |||||
| end.not_to change(Post, :count) | |||||
| expect(post.reload.title).to eq('新タイトル') | |||||
| end | |||||
| end | |||||
| def youtube_video_item(id:, title:, channel_id:) | |||||
| { | |||||
| 'id' => id, | |||||
| 'snippet' => { | |||||
| 'title' => title, | |||||
| 'channelId' => channel_id, | |||||
| 'publishedAt' => '2026-05-01T12:34:56Z', | |||||
| 'thumbnails' => { | |||||
| 'high' => { | |||||
| 'url' => "https://img.youtube.com/#{id}.jpg" | |||||
| } | |||||
| }, | |||||
| 'tags' => ['tag-a', 'tag-b'] | |||||
| } | |||||
| } | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,93 @@ | |||||
| require 'rails_helper' | |||||
| RSpec.describe Youtube::VideoItem do | |||||
| describe '#initialize' do | |||||
| it 'extracts fields from YouTube video API item' do | |||||
| item = { | |||||
| 'id' => 'video-1', | |||||
| 'snippet' => { | |||||
| 'title' => 'テスト動画', | |||||
| 'channelId' => 'UC123', | |||||
| 'publishedAt' => '2026-05-01T12:34:56Z', | |||||
| 'tags' => ['tag-a', 'tag-b'], | |||||
| 'thumbnails' => { | |||||
| 'high' => { | |||||
| 'url' => 'https://img.youtube.com/high.jpg' | |||||
| }, | |||||
| 'medium' => { | |||||
| 'url' => 'https://img.youtube.com/medium.jpg' | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| video = described_class.new(item) | |||||
| expect(video.id).to eq('video-1') | |||||
| expect(video.title).to eq('テスト動画') | |||||
| expect(video.channel_id).to eq('UC123') | |||||
| expect(video.published_at).to eq(Time.iso8601('2026-05-01T12:34:56Z')) | |||||
| expect(video.thumbnail_url).to eq('https://img.youtube.com/high.jpg') | |||||
| expect(video.raw_tags).to eq(['tag-a', 'tag-b']) | |||||
| expect(video.url).to eq('https://www.youtube.com/watch?v=video-1') | |||||
| end | |||||
| it 'uses highest priority thumbnail' do | |||||
| item = { | |||||
| 'id' => 'video-1', | |||||
| 'snippet' => { | |||||
| 'title' => 'テスト動画', | |||||
| 'channelId' => 'UC123', | |||||
| 'publishedAt' => '2026-05-01T12:34:56Z', | |||||
| 'thumbnails' => { | |||||
| 'default' => { | |||||
| 'url' => 'https://img.youtube.com/default.jpg' | |||||
| }, | |||||
| 'standard' => { | |||||
| 'url' => 'https://img.youtube.com/standard.jpg' | |||||
| }, | |||||
| 'maxres' => { | |||||
| 'url' => 'https://img.youtube.com/maxres.jpg' | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| video = described_class.new(item) | |||||
| expect(video.thumbnail_url).to eq('https://img.youtube.com/maxres.jpg') | |||||
| end | |||||
| it 'falls back to empty raw tags' do | |||||
| item = { | |||||
| 'id' => 'video-1', | |||||
| 'snippet' => { | |||||
| 'title' => 'テスト動画', | |||||
| 'channelId' => 'UC123', | |||||
| 'publishedAt' => '2026-05-01T12:34:56Z', | |||||
| 'thumbnails' => {} | |||||
| } | |||||
| } | |||||
| video = described_class.new(item) | |||||
| expect(video.raw_tags).to eq([]) | |||||
| end | |||||
| it 'returns nil thumbnail when no thumbnail exists' do | |||||
| item = { | |||||
| 'id' => 'video-1', | |||||
| 'snippet' => { | |||||
| 'title' => 'テスト動画', | |||||
| 'channelId' => 'UC123', | |||||
| 'publishedAt' => '2026-05-01T12:34:56Z', | |||||
| 'thumbnails' => {} | |||||
| } | |||||
| } | |||||
| video = described_class.new(item) | |||||
| expect(video.thumbnail_url).to be_nil | |||||
| end | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,25 @@ | |||||
| require 'rails_helper' | |||||
| require 'rake' | |||||
| RSpec.describe 'post:sync' do | |||||
| around do |example| | |||||
| original_application = Rake.application | |||||
| Rake.application = Rake::Application.new | |||||
| Rake::Task.define_task(:environment) | |||||
| load Rails.root.join('lib/tasks/sync_posts.rake') | |||||
| example.run | |||||
| ensure | |||||
| Rake.application = original_application | |||||
| end | |||||
| it 'runs Youtube::Sync' do | |||||
| sync = instance_double(Youtube::Sync) | |||||
| expect(Youtube::Sync).to receive(:new).once.and_return(sync) | |||||
| expect(sync).to receive(:sync!).once | |||||
| Rake::Task['post:sync'].invoke | |||||
| end | |||||
| end | |||||