diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb
index 31e3d57..8a18ce9 100644
--- a/backend/app/models/post.rb
+++ b/backend/app/models/post.rb
@@ -2,6 +2,8 @@ class Post < ApplicationRecord
require 'mini_magick'
require 'stringio'
+ class RemoteThumbnailFetchFailed < StandardError; end
+
ORIGINAL_CREATED_INVALID_MESSAGE = 'オリジナルの作成日時の形式が不正です.'.freeze
ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE =
'オリジナルの作成日時は分単位で入力してください.'.freeze
@@ -12,7 +14,9 @@ class Post < ApplicationRecord
def self.resized_thumbnail_attachment(upload)
upload.rewind
image = MiniMagick::Image.read(upload.read)
- image.resize '180x180'
+ image.resize '180x180^'
+ image.gravity 'Center'
+ image.extent '180x180'
image.format 'jpg'
{ io: StringIO.new(image.to_blob),
@@ -125,7 +129,11 @@ class Post < ApplicationRecord
'%d:%02d' % [min, s]
end
- remainder_ms.positive? ? "#{ base }.#{ remainder_ms.to_s.rjust(3, '0') }" : base
+ if remainder_ms.positive?
+ "#{ base }.#{ remainder_ms.to_s.rjust(3, '0') }"
+ else
+ base
+ end
end
def snapshot_parent_post_ids = parents.order(:id).pluck(:id)
@@ -147,8 +155,33 @@ class Post < ApplicationRecord
thumbnail.attach(self.class.resized_thumbnail_attachment(StringIO.new(thumbnail.download)))
end
+ def attach_thumbnail_from_url! raw_url
+ response = Preview::ThumbnailFetcher.fetch_image_response(raw_url)
+ thumbnail.attach(
+ io: StringIO.new(response.body),
+ filename: remote_thumbnail_filename(response.url),
+ content_type: response.content_type)
+ resized_thumbnail!
+ rescue Preview::UrlSafety::UnsafeUrl,
+ Preview::ThumbnailFetcher::GenerationFailed,
+ Preview::HttpFetcher::FetchFailed,
+ Preview::HttpFetcher::FetchTimeout,
+ Preview::HttpFetcher::ResponseTooLarge,
+ MiniMagick::Error => e
+ raise RemoteThumbnailFetchFailed, e.message
+ end
+
private
+ def remote_thumbnail_filename url
+ filename = File.basename(URI.parse(url).path.to_s)
+ return filename if filename.present? && filename != '/'
+
+ 'thumbnail'
+ rescue URI::InvalidURIError
+ 'thumbnail'
+ end
+
def validate_original_created_range
f = parse_original_created_value(:original_created_from)
b = parse_original_created_value(:original_created_before)
diff --git a/backend/app/services/post_creator.rb b/backend/app/services/post_creator.rb
index 29ab18b..0ef0dde 100644
--- a/backend/app/services/post_creator.rb
+++ b/backend/app/services/post_creator.rb
@@ -1,9 +1,12 @@
class PostCreator
class VideoMsParseError < ArgumentError; end
+ attr_reader :field_warnings
+
def initialize actor:, attributes:
@actor = actor
@attributes = attributes.symbolize_keys
+ @field_warnings = { }
end
def create!
@@ -13,8 +16,7 @@ class PostCreator
uploaded_user: @actor,
original_created_from: @attributes[:original_created_from].presence,
original_created_before: @attributes[:original_created_before].presence)
- thumbnail = @attributes[:thumbnail]
- post.thumbnail.attach(Post.resized_thumbnail_attachment(thumbnail)) if thumbnail.present?
+ attach_thumbnail!(post)
ApplicationRecord.transaction do
post.save!
@@ -34,6 +36,21 @@ class PostCreator
private
+ def attach_thumbnail! post
+ thumbnail = @attributes[:thumbnail]
+ if thumbnail.present?
+ post.thumbnail.attach(Post.resized_thumbnail_attachment(thumbnail))
+ return
+ end
+
+ thumbnail_base = post.thumbnail_base
+ return if thumbnail_base.blank?
+
+ post.attach_thumbnail_from_url!(thumbnail_base)
+ rescue Post::RemoteThumbnailFetchFailed => e
+ @field_warnings[:thumbnail_base] = [e.message]
+ end
+
def tag_names = @attributes[:tags].to_s.split
def parent_post_ids
diff --git a/backend/app/services/post_import_runner.rb b/backend/app/services/post_import_runner.rb
index 0e4f69d..1f53c54 100644
--- a/backend/app/services/post_import_runner.rb
+++ b/backend/app/services/post_import_runner.rb
@@ -36,8 +36,13 @@ class PostImportRunner
attributes['tags'] = preview[:attributes]['tags']
attributes['url'] = row['url']
- post = PostCreator.new(actor: @actor, attributes:).create!
- { source_row: row['source_row'], status: 'created', post: PostRepr.base(post) }
+ creator = PostCreator.new(actor: @actor, attributes:)
+ post = creator.create!
+ result = { source_row: row['source_row'], status: 'created', post: PostRepr.base(post) }
+ if creator.field_warnings.present?
+ result[:field_warnings] = creator.field_warnings
+ end
+ result
rescue ActiveRecord::RecordInvalid => e
existing_post = existing_post_for_race(row, e.record)
if existing_post
diff --git a/backend/app/services/preview/thumbnail_fetcher.rb b/backend/app/services/preview/thumbnail_fetcher.rb
index 746aa13..0be0f98 100644
--- a/backend/app/services/preview/thumbnail_fetcher.rb
+++ b/backend/app/services/preview/thumbnail_fetcher.rb
@@ -25,6 +25,22 @@ module Preview
fetch_image!(metadata[:image_url])
end
+ def self.fetch_image_response(raw_url)
+ uri, = UrlSafety.validate(raw_url)
+ response = HttpFetcher.fetch(uri.to_s)
+ unless allowed_image_content_type?(response.content_type)
+ raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
+ end
+
+ response
+ rescue HttpFetcher::FetchTimeout
+ raise
+ rescue HttpFetcher::ResponseTooLarge
+ raise
+ rescue HttpFetcher::FetchFailed
+ raise GenerationFailed, 'サムネール画像を取得できませんでした.'
+ end
+
def self.title(raw_url)
uri, = UrlSafety.validate(raw_url)
HtmlMetadataExtractor.extract(
@@ -45,18 +61,7 @@ module Preview
end
def self.fetch_image!(url)
- response = HttpFetcher.fetch(url)
- unless allowed_image_content_type?(response.content_type)
- raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
- end
-
- response.body
- rescue HttpFetcher::FetchTimeout
- raise
- rescue HttpFetcher::ResponseTooLarge
- raise
- rescue HttpFetcher::FetchFailed
- raise GenerationFailed, 'サムネール画像を取得できませんでした.'
+ fetch_image_response(url).body
end
def self.niconico_thumbnail_url(uri)
@@ -70,16 +75,20 @@ module Preview
xml.at_xpath('//thumbnail_url')&.text&.strip.presence
rescue HttpFetcher::FetchFailed, HttpFetcher::FetchTimeout => e
- Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s,
- video_id:,
- error: e.class.name,
- message: e.message }.to_json }")
+ payload = {
+ url: uri.to_s,
+ video_id:,
+ error: e.class.name,
+ message: e.message }
+ Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ payload.to_json }")
nil
rescue Nokogiri::XML::SyntaxError => e
- Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s,
- video_id:,
- error: e.class.name,
- message: e.message }.to_json }")
+ payload = {
+ url: uri.to_s,
+ video_id:,
+ error: e.class.name,
+ message: e.message }
+ Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ payload.to_json }")
nil
end
diff --git a/backend/app/services/youtube/sync.rb b/backend/app/services/youtube/sync.rb
index 2056dc2..7fe46f0 100644
--- a/backend/app/services/youtube/sync.rb
+++ b/backend/app/services/youtube/sync.rb
@@ -1,4 +1,3 @@
-require 'open-uri'
require 'set'
require 'time'
@@ -127,12 +126,12 @@ module Youtube
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!
+ post.attach_thumbnail_from_url!(thumbnail_url)
+ rescue Post::RemoteThumbnailFetchFailed => e
+ Rails.logger.info("youtube_sync_thumbnail_fetch_failed #{ { post_id: post.id,
+ thumbnail_url:,
+ error: e.class.name,
+ message: e.message }.to_json }")
end
def youtube_url_regexp id
diff --git a/backend/lib/tasks/sync_nico.rake b/backend/lib/tasks/sync_nico.rake
index f7c760c..7aca540 100644
--- a/backend/lib/tasks/sync_nico.rake
+++ b/backend/lib/tasks/sync_nico.rake
@@ -70,11 +70,17 @@ namespace :nico do
unless post.thumbnail.attached?
thumbnail_base = fetch_thumbnail.(post.url) rescue nil
if thumbnail_base.present?
- post.thumbnail.attach(
- io: URI.open(thumbnail_base),
- filename: File.basename(URI.parse(thumbnail_base).path),
- content_type: 'image/jpeg')
attrs[:thumbnail_base] = thumbnail_base
+ begin
+ post.attach_thumbnail_from_url!(thumbnail_base)
+ rescue Post::RemoteThumbnailFetchFailed => e
+ payload = {
+ post_id: post.id,
+ thumbnail_base:,
+ error: e.class.name,
+ message: e.message }
+ Rails.logger.info("nico_sync_thumbnail_fetch_failed #{ payload.to_json }")
+ end
end
end
@@ -82,7 +88,6 @@ namespace :nico do
post_changed = post.changed?
if post_changed
post.save!
- post.resized_thumbnail! if post.thumbnail.attached?
end
else
post_created = true
@@ -91,13 +96,18 @@ namespace :nico do
post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil,
original_created_from:, original_created_before:)
if thumbnail_base.present?
- post.thumbnail.attach(
- io: URI.open(thumbnail_base),
- filename: File.basename(URI.parse(thumbnail_base).path),
- content_type: 'image/jpeg')
+ begin
+ post.attach_thumbnail_from_url!(thumbnail_base)
+ rescue Post::RemoteThumbnailFetchFailed => e
+ payload = {
+ post_id: nil,
+ thumbnail_base:,
+ error: e.class.name,
+ message: e.message }
+ Rails.logger.info("nico_sync_thumbnail_fetch_failed #{ payload.to_json }")
+ end
end
post.save!
- post.resized_thumbnail!
sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id])
end
diff --git a/backend/spec/models/post_spec.rb b/backend/spec/models/post_spec.rb
index 627b28f..7ef84b4 100644
--- a/backend/spec/models/post_spec.rb
+++ b/backend/spec/models/post_spec.rb
@@ -1,4 +1,5 @@
require 'rails_helper'
+require 'tempfile'
RSpec.describe Post, type: :model do
before do
@@ -65,4 +66,133 @@ RSpec.describe Post, type: :model do
expect(post.errors.details.fetch(:url)).to include(error: :taken, value: post.url)
end
end
+
+ describe 'thumbnail processing' do
+ def image_blob(width:, height:, background:, draw: nil)
+ Tempfile.create(['post-thumbnail', '.png']) do |file|
+ MiniMagick::Tool::Convert.new do |convert|
+ convert.size "#{ width }x#{ height }"
+ convert.xc background
+ draw&.call(convert)
+ convert << file.path
+ end
+ File.binread(file.path)
+ end
+ end
+
+ def upload_for(blob)
+ StringIO.new(blob).tap(&:rewind)
+ end
+
+ def read_image(attachment)
+ blob =
+ attachment.is_a?(Hash) ? attachment.fetch(:io).read : attachment.download
+ MiniMagick::Image.read(blob)
+ end
+
+ def colour_at(image, x, y)
+ image.get_pixels.fetch(y).fetch(x)
+ end
+
+ def expect_green(pixel)
+ expect(pixel[1]).to be > pixel[0] + 40
+ expect(pixel[1]).to be > pixel[2] + 40
+ end
+
+ def expect_red(pixel)
+ expect(pixel[0]).to be > pixel[1] + 40
+ expect(pixel[0]).to be > pixel[2] + 40
+ end
+
+ def expect_blue(pixel)
+ expect(pixel[2]).to be > pixel[0] + 40
+ expect(pixel[2]).to be > pixel[1] + 40
+ end
+
+ describe '.resized_thumbnail_attachment' do
+ it 'centre-crops a wide image to 180x180 without distorting it' do
+ blob = image_blob(
+ width: 360,
+ height: 180,
+ background: 'red',
+ draw: -> convert {
+ convert.fill 'green'
+ convert.draw 'rectangle 90,0 269,179'
+ })
+
+ resized = described_class.resized_thumbnail_attachment(upload_for(blob))
+ image = read_image(resized)
+
+ expect(image.dimensions).to eq([180, 180])
+ expect_green(colour_at(image, 0, 90))
+ expect_green(colour_at(image, 90, 90))
+ expect_green(colour_at(image, 179, 90))
+ end
+
+ it 'centre-crops a tall image to 180x180 without distorting it' do
+ blob = image_blob(
+ width: 180,
+ height: 360,
+ background: 'red',
+ draw: -> convert {
+ convert.fill 'green'
+ convert.draw 'rectangle 0,90 179,269'
+ })
+
+ resized = described_class.resized_thumbnail_attachment(upload_for(blob))
+ image = read_image(resized)
+
+ expect(image.dimensions).to eq([180, 180])
+ expect_green(colour_at(image, 90, 0))
+ expect_green(colour_at(image, 90, 90))
+ expect_green(colour_at(image, 90, 179))
+ end
+
+ it 'keeps a square image square without distortion' do
+ blob = image_blob(
+ width: 180,
+ height: 180,
+ background: 'red',
+ draw: -> convert {
+ convert.fill 'blue'
+ convert.draw 'rectangle 90,0 179,179'
+ })
+
+ resized = described_class.resized_thumbnail_attachment(upload_for(blob))
+ image = read_image(resized)
+
+ expect(image.dimensions).to eq([180, 180])
+ expect_red(colour_at(image, 20, 90))
+ expect_blue(colour_at(image, 160, 90))
+ end
+ end
+
+ describe '#attach_thumbnail_from_url!' do
+ it 'attaches a fetched remote image through the common resize path' do
+ blob = image_blob(
+ width: 240,
+ height: 180,
+ background: 'red',
+ draw: -> convert {
+ convert.fill 'green'
+ convert.draw 'rectangle 30,0 209,179'
+ })
+ response = Preview::HttpFetcher::Response.new(
+ blob,
+ 'image/png',
+ 'https://example.com/thumb.png')
+ allow(Preview::ThumbnailFetcher).to receive(:fetch_image_response)
+ .with('https://example.com/thumb.png')
+ .and_return(response)
+
+ post = described_class.create!(title: 'title', url: 'https://example.com/post')
+
+ post.attach_thumbnail_from_url!('https://example.com/thumb.png')
+
+ expect(post.thumbnail).to be_attached
+ image = read_image(post.thumbnail)
+ expect(image.dimensions).to eq([180, 180])
+ end
+ end
+ end
end
diff --git a/backend/spec/services/post_creator_spec.rb b/backend/spec/services/post_creator_spec.rb
new file mode 100644
index 0000000..29a069d
--- /dev/null
+++ b/backend/spec/services/post_creator_spec.rb
@@ -0,0 +1,82 @@
+require 'rails_helper'
+require 'base64'
+
+RSpec.describe PostCreator do
+ let(:actor) { create(:user, :member) }
+
+ def real_thumbnail_upload
+ gif = Base64.decode64('R0lGODdhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=')
+
+ Rack::Test::UploadedFile.new(
+ StringIO.new(gif),
+ 'image/gif',
+ original_filename: 'thumbnail.gif')
+ end
+
+ before do
+ allow(Tag).to receive(:normalise_tags!).and_return({ tags: [], sections: {} })
+ allow(TagVersioning).to receive(:record_tag_snapshots!)
+ allow(Tag).to receive(:expand_parent_tags).and_return([])
+ allow(PostVersionRecorder).to receive(:record!)
+ end
+
+ it 'prefers an explicit upload over thumbnail_base' do
+ allow(Post).to receive(:resized_thumbnail_attachment).and_return(
+ io: StringIO.new('upload'),
+ filename: 'resized_thumbnail.jpg',
+ content_type: 'image/jpeg')
+ expect_any_instance_of(Post).not_to receive(:attach_thumbnail_from_url!)
+
+ post = described_class.new(
+ actor:,
+ attributes: {
+ title: 'title',
+ url: 'https://example.com/post',
+ thumbnail: real_thumbnail_upload,
+ thumbnail_base: 'https://example.com/thumb.jpg',
+ tags: '' }).create!
+
+ expect(post.thumbnail).to be_attached
+ expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg')
+ end
+
+ it 'uses the common remote thumbnail attach path when thumbnail_base is given' do
+ expect_any_instance_of(Post).to receive(:attach_thumbnail_from_url!)
+ .with('https://example.com/thumb.jpg') do |post, _url|
+ post.thumbnail.attach(
+ io: StringIO.new('thumbnail'),
+ filename: 'thumbnail.jpg',
+ content_type: 'image/jpeg')
+ end
+
+ post = described_class.new(
+ actor:,
+ attributes: {
+ title: 'title',
+ url: 'https://example.com/post',
+ thumbnail_base: 'https://example.com/thumb.jpg',
+ tags: '' }).create!
+
+ expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg')
+ expect(post.thumbnail).to be_attached
+ end
+
+ it 'keeps creating the post and records a warning when remote thumbnail fetch fails' do
+ allow_any_instance_of(Post).to receive(:attach_thumbnail_from_url!)
+ .and_raise(Post::RemoteThumbnailFetchFailed, 'サムネール画像を取得できませんでした.')
+ creator = described_class.new(
+ actor:,
+ attributes: {
+ title: 'title',
+ url: 'https://example.com/post',
+ thumbnail_base: 'https://example.com/thumb.jpg',
+ tags: '' })
+
+ post = creator.create!
+
+ expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg')
+ expect(post.thumbnail).not_to be_attached
+ expect(creator.field_warnings).to eq(
+ thumbnail_base: ['サムネール画像を取得できませんでした.'])
+ end
+end
diff --git a/backend/spec/services/post_import_runner_spec.rb b/backend/spec/services/post_import_runner_spec.rb
index 235dabf..00828d2 100644
--- a/backend/spec/services/post_import_runner_spec.rb
+++ b/backend/spec/services/post_import_runner_spec.rb
@@ -130,4 +130,23 @@ RSpec.describe PostImportRunner do
expect(result).to include(status: 'skipped', existing_post_id: existing.id)
end
+
+ it 'returns thumbnail warnings from PostCreator on a created row' do
+ previewer = instance_double(PostImportPreviewer)
+ allow(PostImportPreviewer).to receive(:new).and_return(previewer)
+ allow(previewer).to receive(:preview_rows).and_return([preview])
+ created_post = create(:post)
+ creator = instance_double(
+ PostCreator,
+ create!: created_post,
+ field_warnings: { thumbnail_base: ['サムネール画像を取得できませんでした.'] })
+ allow(PostCreator).to receive(:new).and_return(creator)
+
+ result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first
+
+ expect(result).to include(
+ status: 'created',
+ field_warnings: { thumbnail_base: ['サムネール画像を取得できませんでした.'] }
+ )
+ end
end
diff --git a/backend/spec/services/preview/thumbnail_fetcher_spec.rb b/backend/spec/services/preview/thumbnail_fetcher_spec.rb
index 3088e0d..16bb8cc 100644
--- a/backend/spec/services/preview/thumbnail_fetcher_spec.rb
+++ b/backend/spec/services/preview/thumbnail_fetcher_spec.rb
@@ -48,4 +48,56 @@ RSpec.describe Preview::ThumbnailFetcher do
expect(described_class.fetch('https://example.com/page')).to eq('jpeg-bytes')
end
end
+
+ describe '.fetch_image_response' do
+ it 'rejects an unsafe input URL before HTTP fetch' do
+ allow(Preview::UrlSafety).to receive(:validate)
+ .and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
+ expect(Preview::HttpFetcher).not_to receive(:fetch)
+
+ expect {
+ described_class.fetch_image_response('https://unsafe.example.com/thumb.jpg')
+ }.to raise_error(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
+ end
+
+ it 'rejects an unsafe redirect target' do
+ allow(Preview::UrlSafety).to receive(:validate)
+ .and_return([URI.parse('https://example.com/thumb.jpg'), ['8.8.8.8']])
+ allow(Preview::HttpFetcher).to receive(:fetch)
+ .with('https://example.com/thumb.jpg')
+ .and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
+
+ expect {
+ described_class.fetch_image_response('https://example.com/thumb.jpg')
+ }.to raise_error(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
+ end
+
+ it 'rejects an oversized image response' do
+ allow(Preview::UrlSafety).to receive(:validate)
+ .and_return([URI.parse('https://example.com/thumb.jpg'), ['8.8.8.8']])
+ allow(Preview::HttpFetcher).to receive(:fetch)
+ .with('https://example.com/thumb.jpg')
+ .and_raise(Preview::HttpFetcher::ResponseTooLarge, 'too large')
+
+ expect {
+ described_class.fetch_image_response('https://example.com/thumb.jpg')
+ }.to raise_error(Preview::HttpFetcher::ResponseTooLarge)
+ end
+
+ it 'rejects a non-image content type' do
+ allow(Preview::UrlSafety).to receive(:validate)
+ .and_return([URI.parse('https://example.com/thumb.jpg'), ['8.8.8.8']])
+ allow(Preview::HttpFetcher).to receive(:fetch)
+ .with('https://example.com/thumb.jpg')
+ .and_return(
+ Preview::HttpFetcher::Response.new(
+ '',
+ 'text/html',
+ 'https://example.com/thumb.jpg'))
+
+ expect {
+ described_class.fetch_image_response('https://example.com/thumb.jpg')
+ }.to raise_error(Preview::ThumbnailFetcher::GenerationFailed)
+ end
+ end
end
diff --git a/backend/spec/services/youtube/sync_spec.rb b/backend/spec/services/youtube/sync_spec.rb
index df8009a..20de82d 100644
--- a/backend/spec/services/youtube/sync_spec.rb
+++ b/backend/spec/services/youtube/sync_spec.rb
@@ -10,6 +10,21 @@ RSpec.describe Youtube::Sync do
allow(sync).to receive(:attach_thumbnail_if_needed!)
end
+ describe '#attach_thumbnail_if_needed!' do
+ it 'uses the common remote thumbnail attach path' do
+ post = create(:post, thumbnail_base: nil)
+ allow(sync).to receive(:attach_thumbnail_if_needed!).and_call_original
+
+ expect(post).to receive(:attach_thumbnail_from_url!)
+ .with('https://example.com/thumb.jpg')
+
+ sync.send(
+ :attach_thumbnail_if_needed!,
+ post,
+ 'https://example.com/thumb.jpg')
+ end
+ 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([])
diff --git a/backend/spec/tasks/nico_sync_spec.rb b/backend/spec/tasks/nico_sync_spec.rb
index 87b27d7..944417c 100644
--- a/backend/spec/tasks/nico_sync_spec.rb
+++ b/backend/spec/tasks/nico_sync_spec.rb
@@ -60,6 +60,25 @@ RSpec.describe "nico:sync" do
expect(active_tag_names).to include("bot操作")
end
+ it '既存 post のサムネール取得に共通 attach 経路を使ふ' do
+ post = Post.create!(
+ title: 'old',
+ url: 'https://www.nicovideo.jp/watch/sm9',
+ uploaded_user: nil)
+ Tag.bot
+ Tag.tagme
+
+ stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }])
+ allow(URI).to receive(:open)
+ .and_return(
+ StringIO.new(
+ ''))
+ expect(post).to receive(:attach_thumbnail_from_url!)
+ .with('https://example.com/thumb.jpg')
+
+ run_rake_task('nico:sync')
+ end
+
it "既存 post にあった古い nico tag は active から外され、履歴として discard される" do
post = Post.create!(title: "old", url: "https://www.nicovideo.jp/watch/sm9", uploaded_user: nil)
diff --git a/frontend/src/lib/postImportRows.ts b/frontend/src/lib/postImportRows.ts
index a201077..d66583f 100644
--- a/frontend/src/lib/postImportRows.ts
+++ b/frontend/src/lib/postImportRows.ts
@@ -136,6 +136,8 @@ export const mergeImportResults = (
skipReason: undefined,
createdPostId: result.post.id,
existingPostId: undefined,
+ fieldWarnings: result.fieldWarnings ?? row.fieldWarnings,
+ baseWarnings: result.baseWarnings ?? row.baseWarnings,
importErrors: result.errors }
case 'skipped':
return {
@@ -144,6 +146,8 @@ export const mergeImportResults = (
skipReason: 'existing',
createdPostId: undefined,
existingPostId: result.existingPostId,
+ fieldWarnings: result.fieldWarnings ?? row.fieldWarnings,
+ baseWarnings: result.baseWarnings ?? row.baseWarnings,
importErrors: result.errors }
case 'failed':
return {
@@ -152,6 +156,8 @@ export const mergeImportResults = (
skipReason: undefined,
createdPostId: undefined,
existingPostId: undefined,
+ fieldWarnings: result.fieldWarnings ?? row.fieldWarnings,
+ baseWarnings: result.baseWarnings ?? row.baseWarnings,
importErrors: result.errors }
}
})
diff --git a/frontend/src/lib/postImportTypes.ts b/frontend/src/lib/postImportTypes.ts
index a138a49..ef67832 100644
--- a/frontend/src/lib/postImportTypes.ts
+++ b/frontend/src/lib/postImportTypes.ts
@@ -41,15 +41,21 @@ export type PostImportResultRow =
sourceRow: number
status: 'created'
post: { id: number }
+ fieldWarnings?: Record
+ baseWarnings?: string[]
errors?: Record }
| {
sourceRow: number
status: 'skipped'
existingPostId: number
+ fieldWarnings?: Record
+ baseWarnings?: string[]
errors?: Record }
| {
sourceRow: number
status: 'failed'
+ fieldWarnings?: Record
+ baseWarnings?: string[]
errors?: Record
recoverable?: boolean }