上映会改修 (#302) #357

マージ済み
みてるぞ が 13 個のコミットを feature/302 から main へマージ 2026-06-07 02:51:26 +09:00
22個のファイルの変更1290行の追加245行の削除
コミット 81e620c33a の変更だけを表示してゐます - すべてのコミットを表示
+3 -1
ファイルの表示
@@ -12,6 +12,8 @@ class TheatreProgrammesController < ApplicationController
.order(position: :desc).limit(100) .order(position: :desc).limit(100)
.limit(limit) .limit(limit)
render json: programmes.as_json(include: { post: PostRepr::BASE }) render json: programmes.map { |programme|
programme.as_json.merge(post: PostRepr.base(programme.post))
}
end end
end end
+24
ファイルの表示
@@ -0,0 +1,24 @@
class TheatreSkipEventsController < ApplicationController
def index
limit = params[:limit].to_i
limit = 50 if limit <= 0
events =
TheatreSkipEvent
.where(theatre_id: params[:theatre_id])
.includes(:skipped_by_user, :users, :tags, post: { tags: :tag_name })
.order(created_at: :desc)
.limit(limit)
render json: events.map { |event|
{ id: event.id,
theatre_id: event.theatre_id,
post: PostRepr.base(event.post),
skipped_by_user: UserRepr.base(event.skipped_by_user),
voters: event.users.map { |user| UserRepr.base(user) },
tags: event.tags.map { |tag| TagRepr.inline(tag) },
programme_position: event.programme_position,
created_at: event.created_at }
}
end
end
+88 -9
ファイルの表示
@@ -31,9 +31,7 @@ class TheatresController < ApplicationController
post_started_at = theatre.current_post_started_at post_started_at = theatre.current_post_started_at
end end
render json: { render json: theatre_info_json(theatre, host_flg:, post_id:, post_started_at:)
host_flg:, post_id:, post_started_at:,
watching_users: theatre.watching_users.as_json(only: [:id, :name]) }
end end
def next_post def next_post
@@ -44,14 +42,95 @@ class TheatresController < ApplicationController
return head :forbidden if theatre.host_user != current_user return head :forbidden if theatre.host_user != current_user
ApplicationRecord.transaction do ApplicationRecord.transaction do
post = Post.where("url LIKE '%nicovideo.jp%'") theatre.lock!
.order('RAND()') TheatrePostAdvancer.call(theatre:)
.first
theatre.update!(current_post: post, current_post_started_at: Time.current)
position = (theatre.programmes.maximum(:position) || 0) + 1
theatre.programmes.create!(position:, post:)
end end
head :no_content head :no_content
end end
def skip_vote
return head :unauthorized unless current_user
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
skipped = false
ApplicationRecord.transaction do
theatre.lock!
if theatre.current_post
TheatreWatchingUser.find_or_initialize_by(theatre:, user: current_user).tap {
_1.expires_at = 30.seconds.from_now
}.save!
TheatreSkipVote.find_or_create_by!(theatre:, post: theatre.current_post, user: current_user)
vote_status = skip_vote_status(theatre)
if vote_status[:votes_count] >= vote_status[:required_count]
TheatreSkipFinalizer.call(theatre:, user: current_user)
TheatrePostAdvancer.call(theatre:)
skipped = true
end
end
end
theatre.reload
render json: theatre_info_json(theatre, skipped:)
end
def unskip_vote
return head :unauthorized unless current_user
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
if theatre.current_post
TheatreSkipVote.where(theatre:, post: theatre.current_post, user: current_user).delete_all
end
render json: theatre_info_json(theatre, skipped: false)
end
def post_selection_weights
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
render json: TheatrePostSelector.new(theatre:).weight_json
end
private
def theatre_info_json(theatre, host_flg: nil, post_id: nil, post_started_at: nil, skipped: nil)
host_flg = theatre.host_user_id == current_user&.id if host_flg.nil?
post_id = theatre.current_post_id if post_id.nil?
post_started_at = theatre.current_post_started_at if post_started_at.nil?
json = { host_flg:,
post_id:,
post_started_at:,
post_elapsed_ms: post_started_at ? ((Time.current - post_started_at) * 1000).floor : nil,
watching_users: theatre.watching_users.as_json(only: [:id, :name]),
skip_vote: skip_vote_status(theatre) }
json[:skipped] = skipped unless skipped.nil?
json
end
def skip_vote_status(theatre)
watching_user_ids = theatre.watching_users.ids
watching_users_count = watching_user_ids.size
required_count = (watching_users_count / 2) + 1
post = theatre.current_post
votes =
if post
TheatreSkipVote.where(theatre:, post:, user_id: watching_user_ids)
else
TheatreSkipVote.none
end
{ votes_count: post ? votes.count : 0,
required_count:,
watching_users_count:,
voted: post && current_user ? votes.exists?(user_id: current_user.id) : false }
end
end end
+2
ファイルの表示
@@ -8,6 +8,8 @@ class Theatre < ApplicationRecord
has_many :watching_users, through: :active_theatre_watching_users, source: :user has_many :watching_users, through: :active_theatre_watching_users, source: :user
has_many :programmes, class_name: 'TheatreProgramme' has_many :programmes, class_name: 'TheatreProgramme'
has_many :skip_votes, class_name: 'TheatreSkipVote', dependent: :delete_all
has_many :skip_events, class_name: 'TheatreSkipEvent', dependent: :delete_all
belongs_to :host_user, class_name: 'User', optional: true belongs_to :host_user, class_name: 'User', optional: true
belongs_to :current_post, class_name: 'Post', optional: true belongs_to :current_post, class_name: 'Post', optional: true
+10
ファイルの表示
@@ -0,0 +1,10 @@
class TheatreSkipEvent < ApplicationRecord
belongs_to :theatre
belongs_to :post
belongs_to :skipped_by_user, class_name: 'User'
has_many :voters, class_name: 'TheatreSkipEventVoter', dependent: :delete_all
has_many :event_tags, class_name: 'TheatreSkipEventTag', dependent: :delete_all
has_many :users, through: :voters
has_many :tags, through: :event_tags
end
+6
ファイルの表示
@@ -0,0 +1,6 @@
class TheatreSkipEventTag < ApplicationRecord
self.primary_key = :theatre_skip_event_id, :tag_id
belongs_to :theatre_skip_event
belongs_to :tag
end
+6
ファイルの表示
@@ -0,0 +1,6 @@
class TheatreSkipEventVoter < ApplicationRecord
self.primary_key = :theatre_skip_event_id, :user_id
belongs_to :theatre_skip_event
belongs_to :user
end
+7
ファイルの表示
@@ -0,0 +1,7 @@
class TheatreSkipVote < ApplicationRecord
self.primary_key = :theatre_id, :post_id, :user_id
belongs_to :theatre
belongs_to :post
belongs_to :user
end
+29
ファイルの表示
@@ -0,0 +1,29 @@
class TheatrePostAdvancer
def self.call(theatre:)
new(theatre:).call
end
def initialize(theatre:)
@theatre = theatre
end
def call
previous_post = theatre.current_post
post = TheatrePostSelector.new(theatre:).select
TheatreSkipVote.where(theatre:, post: previous_post).delete_all if previous_post
theatre.update!(current_post: post, current_post_started_at: post ? Time.current : nil)
if post
position = (theatre.programmes.maximum(:position) || 0) + 1
theatre.programmes.create!(position:, post:)
end
post
end
private
attr_reader :theatre
end
+92
ファイルの表示
@@ -0,0 +1,92 @@
class TheatrePostSelector
Candidate = Struct.new(:post, :weight, :penalty, :tags, keyword_init: true)
def initialize(theatre:)
@theatre = theatre
end
def select
candidates = weighted_candidates
return nil if candidates.empty?
total = candidates.sum(&:weight)
target = rand * total
candidates.each do |candidate|
target -= candidate.weight
return candidate.post if target <= 0
end
candidates.last.post
end
def weight_json(limit: 20)
candidates = weighted_candidates
sorted = candidates.sort_by { |candidate| [candidate.weight, candidate.post.id] }
{ tag_penalties: tag_penalty_json,
lightest_posts: post_weight_json(sorted.first(limit)),
heaviest_posts: post_weight_json(sorted.reverse.first(limit)) }
end
private
attr_reader :theatre
def weighted_candidates
@weighted_candidates ||= begin
penalties = tag_penalties
posts = eligible_posts.includes(tags: :tag_name).to_a
posts.map do |post|
post_tags = post.tags.to_a
penalty = post_tags.sum { |tag| penalties[tag.id].to_i }
Candidate.new(post:, penalty:, tags: post_tags, weight: 1.0 / (1.0 + penalty))
end
end
end
def eligible_posts
posts = Post.where("url LIKE '%nicovideo.jp%'")
posts = posts.where.not(id: theatre.current_post_id) if theatre.current_post_id
posts
end
def active_user_ids
@active_user_ids ||= theatre.watching_users.ids
end
def tag_penalties
@tag_penalties ||=
if active_user_ids.empty?
{}
else
TheatreSkipEventVoter
.joins(theatre_skip_event: :event_tags)
.where(user_id: active_user_ids)
.group('theatre_skip_event_tags.tag_id')
.count
end
end
def tag_penalty_json
return [] if tag_penalties.empty?
tags = Tag.where(id: tag_penalties.keys).includes(:tag_name).index_by(&:id)
tag_penalties.map { |tag_id, penalty|
tag = tags[tag_id]
next unless tag
{ tag: TagRepr.inline(tag), penalty: }
}.compact.sort_by { |row| [-row[:penalty], row[:tag]['name'].to_s] }
end
def post_weight_json(candidates)
candidates.map { |candidate|
{ post: PostRepr.base(candidate.post),
weight: candidate.weight,
penalty: candidate.penalty,
tags: candidate.tags.map { |tag| TagRepr.inline(tag) } }
}
end
end
+40
ファイルの表示
@@ -0,0 +1,40 @@
class TheatreSkipFinalizer
def self.call(theatre:, user:)
new(theatre:, user:).call
end
def initialize(theatre:, user:)
@theatre = theatre
@user = user
end
def call
return unless theatre.current_post
post = theatre.current_post
voters = TheatreSkipVote.where(theatre:, post:).includes(:user).map(&:user)
return if voters.empty?
event = TheatreSkipEvent.create!(
theatre:,
post:,
skipped_by_user: user,
programme_position: theatre.programmes.maximum(:position))
voters.uniq(&:id).each do |voter|
TheatreSkipEventVoter.create!(theatre_skip_event: event, user: voter)
end
post.tags.find_each do |tag|
TheatreSkipEventTag.create!(theatre_skip_event: event, tag:)
end
TheatreSkipVote.where(theatre:, post:).delete_all
event
end
private
attr_reader :theatre, :user
end
+4
ファイルの表示
@@ -85,10 +85,14 @@ Rails.application.routes.draw do
member do member do
put :watching put :watching
patch :next_post patch :next_post
put :skip_vote
delete :skip_vote, action: :unskip_vote
get :post_selection_weights
end end
resources :comments, controller: :theatre_comments, only: [:index, :create, :destroy] resources :comments, controller: :theatre_comments, only: [:index, :create, :destroy]
resources :programmes, controller: :theatre_programmes, only: [:index] resources :programmes, controller: :theatre_programmes, only: [:index]
resources :skip_events, controller: :theatre_skip_events, only: [:index]
end end
resources :materials, only: [:index, :show, :create, :update, :destroy] resources :materials, only: [:index, :show, :create, :update, :destroy]
+36
ファイルの表示
@@ -0,0 +1,36 @@
class CreateTheatreSkipVotesAndEvents < ActiveRecord::Migration[8.0]
def change
create_table :theatre_skip_votes, primary_key: [:theatre_id, :post_id, :user_id] do |t|
t.references :theatre, null: false, foreign_key: true
t.references :post, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.timestamps
end
create_table :theatre_skip_events do |t|
t.references :theatre, null: false, foreign_key: true
t.references :post, null: false, foreign_key: true
t.references :skipped_by_user, null: false, foreign_key: { to_table: :users }
t.integer :programme_position
t.datetime :created_at, null: false
end
create_table :theatre_skip_event_voters, primary_key: [:theatre_skip_event_id, :user_id] do |t|
t.references :theatre_skip_event, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
end
create_table :theatre_skip_event_tags, primary_key: [:theatre_skip_event_id, :tag_id] do |t|
t.references :theatre_skip_event, null: false, foreign_key: true
t.references :tag, null: false, foreign_key: true
end
add_index :theatre_skip_events, [:theatre_id, :created_at]
add_index :theatre_skip_votes, [:theatre_id, :post_id, :created_at],
name: 'idx_theatre_skip_votes_theatre_post_created'
add_index :theatre_skip_event_voters, [:user_id, :theatre_skip_event_id],
name: 'idx_theatre_skip_event_voters_user_event'
add_index :theatre_skip_event_tags, [:tag_id, :theatre_skip_event_id],
name: 'idx_theatre_skip_event_tags_tag_event'
end
end
生成ファイル
+69 -1
ファイルの表示
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_05_14_221900) do ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -137,6 +137,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_14_221900) do
t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id" t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
end end
create_table "post_tag_sections", primary_key: ["post_id", "tag_id", "begin_ms"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "tag_id", null: false
t.integer "begin_ms", null: false
t.integer "end_ms", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["post_id", "begin_ms"], name: "idx_post_tag_sections_post_id_begin_ms"
t.index ["tag_id"], name: "fk_rails_8be3847903"
t.check_constraint "`begin_ms` < `end_ms`", name: "chk_post_tag_sections_end_ms_after_begin_ms"
t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural"
end
create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false t.bigint "post_id", null: false
t.bigint "tag_id", null: false t.bigint "tag_id", null: false
@@ -187,8 +200,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_14_221900) do
t.datetime "original_created_before" t.datetime "original_created_before"
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "version_no", null: false t.integer "version_no", null: false
t.integer "video_ms"
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
t.index ["url"], name: "index_posts_on_url", unique: true t.index ["url"], name: "index_posts_on_url", unique: true
t.index ["video_ms", "id"], name: "idx_posts_video_ms_id"
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive"
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end end
@@ -292,6 +308,46 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_14_221900) do
t.index ["theatre_id"], name: "index_theatre_programmes_on_theatre_id" t.index ["theatre_id"], name: "index_theatre_programmes_on_theatre_id"
end end
create_table "theatre_skip_event_tags", primary_key: ["theatre_skip_event_id", "tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_skip_event_id", null: false
t.bigint "tag_id", null: false
t.index ["tag_id", "theatre_skip_event_id"], name: "idx_theatre_skip_event_tags_tag_event"
t.index ["tag_id"], name: "index_theatre_skip_event_tags_on_tag_id"
t.index ["theatre_skip_event_id"], name: "index_theatre_skip_event_tags_on_theatre_skip_event_id"
end
create_table "theatre_skip_event_voters", primary_key: ["theatre_skip_event_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_skip_event_id", null: false
t.bigint "user_id", null: false
t.index ["theatre_skip_event_id"], name: "index_theatre_skip_event_voters_on_theatre_skip_event_id"
t.index ["user_id", "theatre_skip_event_id"], name: "idx_theatre_skip_event_voters_user_event"
t.index ["user_id"], name: "index_theatre_skip_event_voters_on_user_id"
end
create_table "theatre_skip_events", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_id", null: false
t.bigint "post_id", null: false
t.bigint "skipped_by_user_id", null: false
t.integer "programme_position"
t.datetime "created_at", null: false
t.index ["post_id"], name: "index_theatre_skip_events_on_post_id"
t.index ["skipped_by_user_id"], name: "index_theatre_skip_events_on_skipped_by_user_id"
t.index ["theatre_id", "created_at"], name: "index_theatre_skip_events_on_theatre_id_and_created_at"
t.index ["theatre_id"], name: "index_theatre_skip_events_on_theatre_id"
end
create_table "theatre_skip_votes", primary_key: ["theatre_id", "post_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_id", null: false
t.bigint "post_id", null: false
t.bigint "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["post_id"], name: "index_theatre_skip_votes_on_post_id"
t.index ["theatre_id", "post_id", "created_at"], name: "idx_theatre_skip_votes_theatre_post_created"
t.index ["theatre_id"], name: "index_theatre_skip_votes_on_theatre_id"
t.index ["user_id"], name: "index_theatre_skip_votes_on_user_id"
end
create_table "theatre_watching_users", primary_key: ["theatre_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "theatre_watching_users", primary_key: ["theatre_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_id", null: false t.bigint "theatre_id", null: false
t.bigint "user_id", null: false t.bigint "user_id", null: false
@@ -456,6 +512,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_14_221900) do
add_foreign_key "post_implications", "posts", column: "parent_post_id" add_foreign_key "post_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id" add_foreign_key "post_similarities", "posts", column: "target_post_id"
add_foreign_key "post_tag_sections", "posts"
add_foreign_key "post_tag_sections", "tags"
add_foreign_key "post_tags", "posts" add_foreign_key "post_tags", "posts"
add_foreign_key "post_tags", "tags" add_foreign_key "post_tags", "tags"
add_foreign_key "post_tags", "users", column: "created_user_id" add_foreign_key "post_tags", "users", column: "created_user_id"
@@ -476,6 +534,16 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_14_221900) do
add_foreign_key "theatre_comments", "users" add_foreign_key "theatre_comments", "users"
add_foreign_key "theatre_programmes", "posts" add_foreign_key "theatre_programmes", "posts"
add_foreign_key "theatre_programmes", "theatres" add_foreign_key "theatre_programmes", "theatres"
add_foreign_key "theatre_skip_event_tags", "tags"
add_foreign_key "theatre_skip_event_tags", "theatre_skip_events"
add_foreign_key "theatre_skip_event_voters", "theatre_skip_events"
add_foreign_key "theatre_skip_event_voters", "users"
add_foreign_key "theatre_skip_events", "posts"
add_foreign_key "theatre_skip_events", "theatres"
add_foreign_key "theatre_skip_events", "users", column: "skipped_by_user_id"
add_foreign_key "theatre_skip_votes", "posts"
add_foreign_key "theatre_skip_votes", "theatres"
add_foreign_key "theatre_skip_votes", "users"
add_foreign_key "theatre_watching_users", "theatres" add_foreign_key "theatre_watching_users", "theatres"
add_foreign_key "theatre_watching_users", "users" add_foreign_key "theatre_watching_users", "users"
add_foreign_key "theatres", "posts", column: "current_post_id" add_foreign_key "theatres", "posts", column: "current_post_id"
+31
ファイルの表示
@@ -0,0 +1,31 @@
require 'rails_helper'
RSpec.describe 'TheatreProgrammes', type: :request do
describe 'GET /theatres/:theatre_id/programmes' do
let(:theatre) { create(:theatre) }
let(:other_theatre) { create(:theatre) }
let(:post_1) { Post.create!(title: 'first', url: 'https://www.nicovideo.jp/watch/sm1') }
let(:post_2) { Post.create!(title: 'second', url: 'https://www.nicovideo.jp/watch/sm2') }
let(:other_post) { Post.create!(title: 'other', url: 'https://www.nicovideo.jp/watch/sm3') }
before do
TheatreProgramme.create!(theatre:, position: 1, post: post_1, created_at: 2.minutes.ago)
TheatreProgramme.create!(theatre:, position: 2, post: post_2, created_at: 1.minute.ago)
TheatreProgramme.create!(
theatre: other_theatre,
position: 1,
post: other_post,
created_at: 1.minute.ago
)
end
it 'returns programmes for the theatre in descending position with post json' do
get "/theatres/#{theatre.id}/programmes"
expect(response).to have_http_status(:ok)
expect(json.map { _1['position'] }).to eq([2, 1])
expect(json.map { _1.dig('post', 'title') }).to eq(['second', 'first'])
expect(json.first['post']).to include('id' => post_2.id, 'url' => post_2.url)
end
end
end
+133 -11
ファイルの表示
@@ -14,10 +14,17 @@ RSpec.describe 'Theatres API', type: :request do
let(:member) { create(:user, :member, name: 'member user') } let(:member) { create(:user, :member, name: 'member user') }
let(:other_user) { create(:user, :member, name: 'other user') } let(:other_user) { create(:user, :member, name: 'other user') }
let!(:youtube_post) do let!(:niconico_post) do
Post.create!( Post.create!(
title: 'youtube post', title: 'niconico post',
url: 'https://www.youtube.com/watch?v=spec123' url: 'https://www.nicovideo.jp/watch/sm123'
)
end
let!(:second_niconico_post) do
Post.create!(
title: 'second niconico post',
url: 'https://www.nicovideo.jp/watch/sm456'
) )
end end
@@ -120,7 +127,8 @@ RSpec.describe 'Theatres API', type: :request do
expect(json).to include( expect(json).to include(
'host_flg' => true, 'host_flg' => true,
'post_id' => nil, 'post_id' => nil,
'post_started_at' => nil 'post_started_at' => nil,
'post_elapsed_ms' => nil
) )
expect(json.fetch('watching_users')).to contain_exactly( expect(json.fetch('watching_users')).to contain_exactly(
@@ -177,7 +185,8 @@ RSpec.describe 'Theatres API', type: :request do
expect(json).to include( expect(json).to include(
'host_flg' => false, 'host_flg' => false,
'post_id' => nil, 'post_id' => nil,
'post_started_at' => nil 'post_started_at' => nil,
'post_elapsed_ms' => nil
) )
expect(json.fetch('watching_users')).to contain_exactly( expect(json.fetch('watching_users')).to contain_exactly(
@@ -204,7 +213,7 @@ RSpec.describe 'Theatres API', type: :request do
) )
theatre.update!( theatre.update!(
host_user: other_user, host_user: other_user,
current_post: youtube_post, current_post: niconico_post,
current_post_started_at: started_at current_post_started_at: started_at
) )
sign_in_as(member) sign_in_as(member)
@@ -220,9 +229,11 @@ RSpec.describe 'Theatres API', type: :request do
expect(theatre.host_user_id).to eq(member.id) expect(theatre.host_user_id).to eq(member.id)
expect(json['host_flg']).to eq(true) expect(json['host_flg']).to eq(true)
expect(json['post_id']).to eq(youtube_post.id) expect(json['post_id']).to eq(niconico_post.id)
expect(Time.zone.parse(json['post_started_at'])) expect(Time.zone.parse(json['post_started_at']))
.to be_within(1.second).of(started_at) .to be_within(1.second).of(started_at)
expect(json['post_elapsed_ms'])
.to be_within(1_000).of(120_000)
end end
end end
end end
@@ -273,17 +284,20 @@ RSpec.describe 'Theatres API', type: :request do
it 'sets current_post to an eligible post and updates current_post_started_at' do it 'sets current_post to an eligible post and updates current_post_started_at' do
expect { do_request } expect { do_request }
.to change { theatre.reload.current_post_id } .to change { theatre.reload.current_post_id }
.from(nil).to(youtube_post.id)
expect(response).to have_http_status(:no_content) expect(response).to have_http_status(:no_content)
expect([niconico_post.id, second_niconico_post.id])
.to include(theatre.reload.current_post_id)
expect(theatre.reload.current_post_started_at) expect(theatre.reload.current_post_started_at)
.to be_within(1.second).of(Time.current) .to be_within(1.second).of(Time.current)
expect(theatre.programmes.count).to eq(1)
end end
end end
context 'when current user is host and no eligible post exists' do context 'when current user is host and no eligible post exists' do
before do before do
youtube_post.destroy! niconico_post.destroy!
second_niconico_post.destroy!
theatre.update!( theatre.update!(
host_user: member, host_user: member,
current_post: other_post, current_post: other_post,
@@ -299,9 +313,117 @@ RSpec.describe 'Theatres API', type: :request do
theatre.reload theatre.reload
expect(theatre.current_post_id).to be_nil expect(theatre.current_post_id).to be_nil
expect(theatre.current_post_started_at) expect(theatre.current_post_started_at).to be_nil
.to be_within(1.second).of(Time.current)
end end
end end
end end
describe 'PUT /theatres/:id/skip_vote' do
subject(:do_request) do
put "/theatres/#{theatre.id}/skip_vote"
end
let(:third_user) { create(:user, :member, name: 'third user') }
before do
theatre.update!(current_post: niconico_post, current_post_started_at: 10.seconds.ago)
[member, other_user, third_user].each do |user|
TheatreWatchingUser.create!(
theatre:,
user:,
expires_at: 10.seconds.from_now
)
end
end
it 'records a vote and returns the current vote status before majority' do
sign_in_as(member)
expect { do_request }.to change(TheatreSkipVote, :count).by(1)
expect(response).to have_http_status(:ok)
expect(json['skipped']).to eq(false)
expect(json['post_id']).to eq(niconico_post.id)
expect(json['skip_vote']).to include(
'votes_count' => 1,
'required_count' => 2,
'watching_users_count' => 3,
'voted' => true
)
end
it 'finalizes skip when votes reach majority and stores voters and tag snapshots' do
tag = create(:tag, name: 'skip-target')
PostTag.create!(post: niconico_post, tag:)
TheatreSkipVote.create!(theatre:, post: niconico_post, user: member)
sign_in_as(other_user)
expect { do_request }
.to change(TheatreSkipEvent, :count).by(1)
.and change(TheatreSkipEventVoter, :count).by(2)
.and change(TheatreSkipEventTag, :count).by(1)
expect(response).to have_http_status(:ok)
expect(json['skipped']).to eq(true)
expect(json['post_id']).to eq(second_niconico_post.id)
event = TheatreSkipEvent.last
expect(event.post).to eq(niconico_post)
expect(event.users).to contain_exactly(member, other_user)
expect(event.tags).to contain_exactly(tag)
expect(TheatreSkipVote.where(theatre:, post: niconico_post)).to be_empty
end
end
describe 'DELETE /theatres/:id/skip_vote' do
before do
theatre.update!(current_post: niconico_post, current_post_started_at: 10.seconds.ago)
TheatreWatchingUser.create!(theatre:, user: member, expires_at: 10.seconds.from_now)
TheatreSkipVote.create!(theatre:, post: niconico_post, user: member)
sign_in_as(member)
end
it 'removes the current user vote' do
expect {
delete "/theatres/#{theatre.id}/skip_vote"
}.to change(TheatreSkipVote, :count).by(-1)
expect(response).to have_http_status(:ok)
expect(json['skip_vote']).to include(
'votes_count' => 0,
'required_count' => 1,
'watching_users_count' => 1,
'voted' => false
)
end
end
describe 'GET /theatres/:id/post_selection_weights' do
before do
theatre.update!(current_post: niconico_post)
TheatreWatchingUser.create!(theatre:, user: member, expires_at: 10.seconds.from_now)
sign_in_as(member)
end
it 'returns tag penalties and candidate weights for the current watchers' do
tag = create(:tag, name: 'heavy-tag')
PostTag.create!(post: second_niconico_post, tag:)
event = TheatreSkipEvent.create!(
theatre:,
post: niconico_post,
skipped_by_user: member,
created_at: Time.current
)
TheatreSkipEventVoter.create!(theatre_skip_event: event, user: member)
TheatreSkipEventTag.create!(theatre_skip_event: event, tag:)
get "/theatres/#{theatre.id}/post_selection_weights"
expect(response).to have_http_status(:ok)
expect(json['tag_penalties'].first['penalty']).to eq(1)
expect(json['lightest_posts'].first['post']['id']).to eq(second_niconico_post.id)
expect(json['lightest_posts'].first['penalty']).to eq(1)
end
end
end end
+8 -4
ファイルの表示
@@ -37,11 +37,12 @@ type Props = {
height: number height: number
style?: CSSProperties style?: CSSProperties
onLoadComplete?: (info: NiconicoVideoInfo) => void onLoadComplete?: (info: NiconicoVideoInfo) => void
onMetadataChange?: (meta: NiconicoMetadata) => void } onMetadataChange?: (meta: NiconicoMetadata) => void
onError?: (data: unknown) => void }
export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle>) => { export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle>) => {
const { id, width, height, style = { }, onLoadComplete, onMetadataChange } = props const { id, width, height, style = { }, onLoadComplete, onMetadataChange, onError } = props
const iframeRef = useRef<HTMLIFrameElement> (null) const iframeRef = useRef<HTMLIFrameElement> (null)
const playerId = useMemo (() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, [id]) const playerId = useMemo (() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, [id])
@@ -173,13 +174,16 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
} }
if (data.eventName === 'error') if (data.eventName === 'error')
console.error ('niconico player error:', data) {
console.error ('niconico player error:', data)
onError?.(data)
}
} }
addEventListener ('message', onMessage) addEventListener ('message', onMessage)
return () => removeEventListener ('message', onMessage) return () => removeEventListener ('message', onMessage)
}, [onLoadComplete, onMetadataChange, playerId]) }, [onError, onLoadComplete, onMetadataChange, playerId])
useLayoutEffect (() => { useLayoutEffect (() => {
if (!(fullScreen)) if (!(fullScreen))
+5 -3
ファイルの表示
@@ -13,10 +13,11 @@ type Props = {
ref?: RefObject<NiconicoViewerHandle | null> ref?: RefObject<NiconicoViewerHandle | null>
post: Post post: Post
onLoadComplete?: (info: NiconicoVideoInfo) => void onLoadComplete?: (info: NiconicoVideoInfo) => void
onMetadataChange?: (meta: NiconicoMetadata) => void } onMetadataChange?: (meta: NiconicoMetadata) => void
onError?: (data: unknown) => void }
const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) => { const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange, onError }) => {
const dialogue = useDialogue () const dialogue = useDialogue ()
const [framed, setFramed] = useState (false) const [framed, setFramed] = useState (false)
@@ -39,7 +40,8 @@ const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) =
width={640} width={640}
height={360} height={360}
onLoadComplete={onLoadComplete} onLoadComplete={onLoadComplete}
onMetadataChange={onMetadataChange}/>) onMetadataChange={onMetadataChange}
onError={onError}/>)
} }
case 'twitter.com': case 'twitter.com':
+7 -3
ファイルの表示
@@ -128,8 +128,12 @@ const TopNav: FC<Props> = ({ user }) => {
const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname }) const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname })
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true) const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
const moreMenu = menu.filter (item =>
!(item.visible ?? true)
|| item.subMenu.filter (subItem => subItem.visible ?? true).length > 0)
const activeIdx = const activeIdx =
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to)) visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40)
const prevActiveIdxRef = useRef<number> (activeIdx) const prevActiveIdxRef = useRef<number> (activeIdx)
@@ -240,9 +244,9 @@ const TopNav: FC<Props> = ({ user }) => {
<motion.div <motion.div
key="submenu-shell" key="submenu-shell"
layout layout
className="relative hidden md:block overflow-hidden className="relative z-20 hidden md:block overflow-hidden
bg-yellow-200 dark:bg-red-950" bg-yellow-200 dark:bg-red-950"
style={{ height: moreVsbl ? 40 * menu.length : (activeIdx < 0 ? 0 : 40) }} animate={{ height: submenuHeight }}
onMouseLeave={() => { onMouseLeave={() => {
if (moreVsbl) if (moreVsbl)
setMoreVsbl (false) setMoreVsbl (false)
@@ -253,7 +257,7 @@ const TopNav: FC<Props> = ({ user }) => {
}}> }}>
{moreVsbl {moreVsbl
? ( ? (
menu.map ((item, i) => ( moreMenu.map ((item, i) => (
<div key={i} className="relative h-[40px]"> <div key={i} className="relative h-[40px]">
<div className="absolute inset-0 flex items-center px-3"> <div className="absolute inset-0 flex items-center px-3">
<motion.div <motion.div
+6 -3
ファイルの表示
@@ -64,11 +64,14 @@ export const apiPatch = async <T> (
): Promise<T> => apiP ('patch', path, body, opt) ): Promise<T> => apiP ('patch', path, body, opt)
export const apiDelete = async ( export const apiDelete = async <T = void> (
path: string, path: string,
opt?: Opt, opt?: Opt,
): Promise<void> => { ): Promise<T> => {
await client.delete (path, withUserCode (opt)) const res = await client.delete (path, withUserCode (opt))
if (res.data == null || res.data === '')
return undefined as T
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
} }
ファイル差分が大きすぎるため省略します 差分を読込み
+42 -2
ファイルの表示
@@ -226,7 +226,7 @@ export type Theatre = {
export type TheatreComment = export type TheatreComment =
| { theatreId: number | { theatreId: number
no: number no: number
deteled: false deleted: false
user: { id: number, name: string } | null user: { id: number, name: string } | null
content: string content: string
createdAt: string } createdAt: string }
@@ -234,7 +234,7 @@ export type TheatreComment =
no: number no: number
deleted: true deleted: true
user: { id: number, name: string } | null user: { id: number, name: string } | null
content null, content: null,
createdAt: string } createdAt: string }
export type TheatreProgramme = { export type TheatreProgramme = {
@@ -243,6 +243,46 @@ export type TheatreProgramme = {
post: Post post: Post
createdAt: string } createdAt: string }
export type TheatreSkipVoteStatus = {
votesCount: number
requiredCount: number
watchingUsersCount: number
voted: boolean }
export type TheatreInfo = {
hostFlg: boolean
postId: number | null
postStartedAt: string | null
postElapsedMs: number | null
watchingUsers: Pick<User, 'id' | 'name'>[]
skipVote: TheatreSkipVoteStatus
skipped?: boolean }
export type TheatreSkipEvent = {
id: number
theatreId: number
post: Post
skippedByUser: Pick<User, 'id' | 'name'>
voters: Pick<User, 'id' | 'name'>[]
tags: Tag[]
programmePosition: number | null
createdAt: string }
export type TheatrePostWeight = {
post: Post
weight: number
penalty: number
tags: Tag[] }
export type TheatreTagPenalty = {
tag: Tag
penalty: number }
export type TheatrePostSelectionWeights = {
tagPenalties: TheatreTagPenalty[]
lightestPosts: TheatrePostWeight[]
heaviestPosts: TheatrePostWeight[] }
export type User = { export type User = {
id: number id: number
name: string | null name: string | null