このコミットが含まれているのは:
2026-07-18 00:01:12 +09:00
コミット ff970f8171
11個のファイルの変更449行の追加173行の削除
+21 -4
ファイルの表示
@@ -1,6 +1,5 @@
class PostsController < ApplicationController
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
MAX_BULK_THUMBNAIL_BYTES = 20 * 1024 * 1024
MAX_BULK_REQUEST_BYTES = 40 * 1024 * 1024
class VideoMsParseError < ArgumentError
@@ -178,7 +177,7 @@ class PostsController < ApplicationController
preflight = PostCreatePreflight.new(
attributes: post_create_attributes,
thumbnail: params[:thumbnail]).run
return render json: preflight if bool?(:dry)
return render json: dry_run_json(preflight) if bool?(:dry)
post = PostCreator.new(actor: current_user,
attributes: post_create_attributes.merge(
@@ -191,7 +190,10 @@ class PostsController < ApplicationController
:original_created_from,
:original_created_before,
:duration,
:video_ms).symbolize_keys).merge(
:video_ms,
:normalised_tags,
:tag_sections,
:normalised_parent_post_ids).symbolize_keys).merge(
thumbnail: params[:thumbnail])).create!
post.reload
@@ -566,7 +568,6 @@ class PostsController < ApplicationController
unless value.is_a?(ActionDispatch::Http::UploadedFile)
raise ArgumentError, 'thumbnail upload が不正です.'
end
raise ArgumentError, 'thumbnail file size が大きすぎます.' if value.size > MAX_BULK_THUMBNAIL_BYTES
thumbnails[index] = value
end
@@ -581,6 +582,22 @@ class PostsController < ApplicationController
PostCompactRepr.base(post)
end
def dry_run_json preflight
preflight.slice(
:url,
:title,
:thumbnail_base,
:tags,
:parent_post_ids,
:original_created_from,
:original_created_before,
:duration,
:video_ms,
:field_warnings,
:base_warnings,
:existing_post)
end
def sync_parent_posts! post, parent_post_ids
if parent_post_ids.include?(post.id)
post.errors.add :parent_post_ids, '自分自身を親投稿にはできません.'
+43 -7
ファイルの表示
@@ -1,21 +1,54 @@
class PostBulkCreator
def initialize actor:, posts:, thumbnails:
@actor = actor
@actor_id = actor.id
@posts = posts
@thumbnails = thumbnails
end
def run
results = @posts.each_with_index.map { |attributes, index|
create_row(attributes, index)
}
results = Array.new(@posts.length)
mutex = Mutex.new
next_index = 0
workers = Array.new(2) do
Thread.new do
ActiveRecord::Base.connection_pool.with_connection do
actor = User.find(@actor_id)
loop do
index = nil
begin
index = mutex.synchronize do
current = next_index
next_index += 1
current
end
break if index >= @posts.length
attributes = @posts[index]
results[index] = create_row(actor, attributes, index)
rescue StandardError => e
Rails.logger.error(
"post_bulk_creator_worker_failure #{ { error: e.class.name,
message: e.message,
index: }.to_json }")
results[index] = {
status: 'failed',
recoverable: false,
errors: { base: ['登録中にエラーが発生しました.'] },
base_errors: [] }
end
end
end
end
end
workers.each(&:join)
{ results: }
end
private
def create_row attributes, index
def create_row actor, attributes, index
preflight =
PostCreatePreflight.new(
attributes: attributes,
@@ -27,7 +60,7 @@ class PostBulkCreator
end
post = PostCreator.new(
actor: @actor,
actor: actor,
attributes: normalised_attributes(attributes, preflight, index)).create!
result = {
status: 'created',
@@ -122,7 +155,10 @@ class PostBulkCreator
original_created_from: preflight[:original_created_from],
original_created_before: preflight[:original_created_before],
duration: preflight[:duration],
video_ms: preflight[:video_ms] }
video_ms: preflight[:video_ms],
normalised_tags: preflight[:normalised_tags],
tag_sections: preflight[:tag_sections],
normalised_parent_post_ids: preflight[:normalised_parent_post_ids] }
end
def thumbnail_for index, attributes
+95
ファイルの表示
@@ -0,0 +1,95 @@
class PostCreatePlan
def initialize attributes:
@attributes = attributes.symbolize_keys
end
def build!
Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true,
with_sections: true) =>
{ tags:, sections: }
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
video_ms = normalise_video_ms(tags)
validate_video_sections!(video_ms, sections)
parent_post_ids = normalise_parent_post_ids
validate_parent_post_ids!(parent_post_ids)
{
url: @attributes[:url],
title: @attributes[:title].to_s,
thumbnail_base: @attributes[:thumbnail_base].presence,
original_created_from: @attributes[:original_created_from].presence,
original_created_before: @attributes[:original_created_before].presence,
tags: serialised_tags(tags, sections),
duration: @attributes[:duration].to_s,
video_ms: video_ms,
parent_post_ids: parent_post_ids.join(' '),
normalised_tags: tags,
tag_sections: sections,
normalised_parent_post_ids: parent_post_ids }
end
private
def tag_names = @attributes[:tags].to_s.split
def normalise_parent_post_ids
Array(@attributes[:parent_post_ids]).flat_map { _1.to_s.split }.map { |token|
id = Integer(token, exception: false)
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0
id
}.uniq.sort
end
def validate_parent_post_ids! ids
missing = ids - Post.where(id: ids).pluck(:id)
raise ArgumentError, "存在しない親投稿 Id. があります: #{ missing.join(' ') }" if missing.present?
end
def serialised_tags tags, sections
tags.uniq(&:id).map { |tag|
"#{ tag.name }#{ sections[tag.id].to_a.map { Post.section_literal(_1) }.join }"
}.sort.join(' ')
end
def normalise_video_ms tags
return nil unless tags.any? { _1.id == Tag.video.id }
video_ms = @attributes[:video_ms]
if video_ms.present?
value = Integer(video_ms, exception: false)
raise PostCreator::VideoMsParseError unless value&.positive?
return value
end
duration = @attributes[:duration]
return nil if duration.blank?
value = Tag.time_to_ms!(duration.to_s, tag_name: '動画時間')
raise PostCreator::VideoMsParseError unless value.positive?
value
rescue Tag::SectionLiteralParseError
raise PostCreator::VideoMsParseError
end
def validate_video_sections! video_ms, sections
return unless video_ms
sections.each_value do |ranges|
ranges.each do |begin_ms, end_ms|
post = Post.new
if begin_ms >= video_ms
post.errors.add :video_ms, 'タグ区間の開始が動画時間以上です.'
raise ActiveRecord::RecordInvalid, post
end
if end_ms && end_ms > video_ms
post.errors.add :video_ms, 'タグ区間の終端が動画時間を超えてゐます.'
raise ActiveRecord::RecordInvalid, post
end
end
end
end
end
+56 -16
ファイルの表示
@@ -18,24 +18,54 @@ class PostCreatePreflight
preview = PostImportPreviewer.new.preview_rows(
rows: [preview_row],
fetch_metadata: false).first
validate_thumbnail_upload!
if preview[:existing_post_id].present?
return {
url: preview[:url],
title: preview[:attributes]['title'],
thumbnail_base: preview[:attributes]['thumbnail_base'],
tags: preview[:attributes]['tags'],
parent_post_ids: preview[:attributes]['parent_post_ids'],
original_created_from: preview[:attributes]['original_created_from'],
original_created_before: preview[:attributes]['original_created_before'],
duration: preview[:attributes]['duration'],
video_ms: preview[:attributes]['video_ms'],
field_warnings: final_field_warnings(preview[:field_warnings] || { }),
base_warnings: preview[:base_warnings],
existing_post: existing_post_compact(preview[:existing_post_id]) }
end
if preview[:validation_errors].present?
raise ValidationFailed.new(fields: preview[:validation_errors])
end
validate_thumbnail_upload!
plan = PostCreatePlan.new(
attributes: {
url: preview[:url],
title: preview[:attributes]['title'],
thumbnail_base: preview[:attributes]['thumbnail_base'],
tags: preview[:attributes]['tags'],
parent_post_ids: preview[:attributes]['parent_post_ids'],
original_created_from: preview[:attributes]['original_created_from'],
original_created_before: preview[:attributes]['original_created_before'],
duration: preview[:attributes]['duration'],
video_ms: preview[:attributes]['video_ms'] }).build!
{
url: preview[:url],
title: preview[:attributes]['title'],
thumbnail_base: preview[:attributes]['thumbnail_base'],
tags: preview[:attributes]['tags'],
parent_post_ids: preview[:attributes]['parent_post_ids'],
original_created_from: preview[:attributes]['original_created_from'],
original_created_before: preview[:attributes]['original_created_before'],
duration: preview[:attributes]['duration'],
video_ms: preview[:attributes]['video_ms'],
field_warnings: preview[:field_warnings],
url: plan[:url],
title: plan[:title],
thumbnail_base: plan[:thumbnail_base],
tags: plan[:tags],
parent_post_ids: plan[:parent_post_ids],
original_created_from: plan[:original_created_from],
original_created_before: plan[:original_created_before],
duration: plan[:duration],
video_ms: plan[:video_ms],
normalised_tags: plan[:normalised_tags],
tag_sections: plan[:tag_sections],
normalised_parent_post_ids: plan[:normalised_parent_post_ids],
field_warnings: final_field_warnings(preview[:field_warnings] || { }),
base_warnings: preview[:base_warnings],
existing_post: existing_post_compact(preview[:existing_post_id]) }
end
@@ -78,10 +108,9 @@ class PostCreatePreflight
return if @attributes[:thumbnail_base].present?
return if @thumbnail.blank?
attachment = Post.resized_thumbnail_attachment(@thumbnail)
attachment[:io].close if attachment[:io].respond_to?(:close)
rescue MiniMagick::Error
raise ValidationFailed.new(fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] })
PostThumbnailUploadValidator.validate!(@thumbnail)
rescue PostThumbnailUploadValidator::InvalidUpload => e
raise ValidationFailed.new(fields: { thumbnail: [e.message] })
end
def existing_post_compact post_id
@@ -90,4 +119,15 @@ class PostCreatePreflight
post = Post.with_attached_thumbnail.find_by(id: post_id)
PostCompactRepr.base(post)
end
def final_field_warnings field_warnings
thumbnail_warnings = (field_warnings['thumbnail_base'] || []).reject { _1 == 'サムネールなし' }
if @attributes[:thumbnail_base].blank? && @thumbnail.blank?
thumbnail_warnings = (thumbnail_warnings + ['サムネールなし']).uniq
end
{
**field_warnings,
'thumbnail_base' => thumbnail_warnings }
end
end
+20 -48
ファイルの表示
@@ -21,15 +21,13 @@ class PostCreator
ApplicationRecord.transaction do
post.save!
post.thumbnail.attach(thumbnail_attachment) if thumbnail_attachment.present?
Tag.normalise_tags!(tag_names, deny_deprecated: true, with_sections: true) =>
{ tags:, sections: }
tags = planned_tags
sections = planned_sections
TagVersioning.record_tag_snapshots!(tags, created_by_user: @actor)
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
post.video_ms = normalise_video_ms(tags)
validate_video_sections!(post.video_ms, sections)
post.video_ms = planned_video_ms
post.save!
sync_post_tags!(post, tags, sections)
sync_parent_posts!(post, parent_post_ids)
sync_parent_posts!(post, planned_parent_post_ids)
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: @actor)
end
post
@@ -46,52 +44,26 @@ class PostCreator
thumbnail_base: @attributes[:thumbnail_base].presence)
end
def tag_names = @attributes[:tags].to_s.split
def planned_tags = planned_create_attributes[:normalised_tags]
def parent_post_ids
Array(@attributes[:parent_post_ids]).flat_map { _1.to_s.split }.map { |token|
id = Integer(token, exception: false)
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0
def planned_sections = planned_create_attributes[:tag_sections]
id
}.uniq
def planned_parent_post_ids = planned_create_attributes[:normalised_parent_post_ids]
def planned_video_ms
planned_create_attributes[:video_ms]
end
def normalise_video_ms tags
return nil unless tags.any? { _1.id == Tag.video.id }
video_ms = @attributes[:video_ms]
if video_ms.present?
value = Integer(video_ms, exception: false)
raise VideoMsParseError unless value&.positive?
return value
end
duration = @attributes[:duration]
return nil if duration.blank?
value = Tag.time_to_ms!(duration.to_s, tag_name: '動画時間')
raise VideoMsParseError unless value.positive?
value
rescue Tag::SectionLiteralParseError
raise VideoMsParseError
end
def validate_video_sections! video_ms, sections
return unless video_ms
sections.each_value do |ranges|
ranges.each do |begin_ms, end_ms|
post = Post.new
if begin_ms >= video_ms
post.errors.add :video_ms, 'タグ区間の開始が動画時間以上です.'
raise ActiveRecord::RecordInvalid, post
end
if end_ms && end_ms > video_ms
post.errors.add :video_ms, 'タグ区間の終端が動画時間を超えてゐます.'
raise ActiveRecord::RecordInvalid, post
end
def planned_create_attributes
@planned_create_attributes ||= begin
if @attributes.key?(:normalised_tags)
{
normalised_tags: @attributes[:normalised_tags],
tag_sections: @attributes[:tag_sections] || { },
normalised_parent_post_ids: @attributes[:normalised_parent_post_ids] || [],
video_ms: @attributes[:video_ms] }
else
PostCreatePlan.new(attributes: @attributes).build!
end
end
end
+2
ファイルの表示
@@ -487,6 +487,8 @@ class PostImportPreviewer
end
def parse_video_ms attributes, errors
return nil unless attributes['tags'].to_s.split.include?('動画')
video_ms = attributes['video_ms']
if video_ms.present?
value = Integer(video_ms, exception: false)
-25
ファイルの表示
@@ -1,25 +0,0 @@
class PostImportUrlListParser
MAX_ROWS = 100
MAX_BYTES = 1.megabyte
MAX_URL_BYTES = 20.kilobytes
def self.parse source
raw = source.to_s
raise ArgumentError, '入力が大きすぎます.' if raw.bytesize > MAX_BYTES
rows = raw.split(/\r\n|\n|\r/).each_with_index.filter_map { |line, index|
url = line.strip
next if url.blank?
if url.bytesize > MAX_URL_BYTES
raise ArgumentError, "#{ index + 1 } 行目: URL が長すぎます."
end
{ source_row: index + 1, url: }
}
raise ArgumentError, 'URL を入力してください.' if rows.empty?
raise ArgumentError, "取込件数は #{ MAX_ROWS } 件までです." if rows.length > MAX_ROWS
rows
end
end
+18
ファイルの表示
@@ -0,0 +1,18 @@
class PostThumbnailUploadValidator
MAX_THUMBNAIL_BYTES = 20 * 1024 * 1024
class InvalidUpload < StandardError; end
def self.validate! thumbnail
return if thumbnail.blank?
raise InvalidUpload, 'thumbnail upload が不正です.' unless thumbnail.is_a?(ActionDispatch::Http::UploadedFile)
raise InvalidUpload, 'thumbnail file size が大きすぎます.' if thumbnail.size > MAX_THUMBNAIL_BYTES
attachment = Post.resized_thumbnail_attachment(thumbnail)
attachment[:io].close if attachment[:io].respond_to?(:close)
rescue MiniMagick::Error
raise InvalidUpload, 'サムネイル画像の変換に失敗しました.'
ensure
thumbnail&.rewind if thumbnail.respond_to?(:rewind)
end
end