From 81e620c33a5748652c9f96317003bba7303d56e8 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 6 Jun 2026 19:49:21 +0900 Subject: [PATCH] #302 --- .../theatre_programmes_controller.rb | 4 +- .../theatre_skip_events_controller.rb | 24 + .../app/controllers/theatres_controller.rb | 97 +- backend/app/models/theatre.rb | 2 + backend/app/models/theatre_skip_event.rb | 10 + backend/app/models/theatre_skip_event_tag.rb | 6 + .../app/models/theatre_skip_event_voter.rb | 6 + backend/app/models/theatre_skip_vote.rb | 7 + backend/app/services/theatre_post_advancer.rb | 29 + backend/app/services/theatre_post_selector.rb | 92 ++ .../app/services/theatre_skip_finalizer.rb | 40 + backend/config/routes.rb | 4 + ...00_create_theatre_skip_votes_and_events.rb | 36 + backend/db/schema.rb | 70 +- .../spec/requests/theatre_programmes_spec.rb | 31 + backend/spec/requests/theatres_spec.rb | 144 ++- frontend/src/components/NicoViewer.tsx | 12 +- frontend/src/components/PostEmbed.tsx | 8 +- frontend/src/components/TopNav.tsx | 10 +- frontend/src/lib/api.ts | 9 +- .../src/pages/theatres/TheatreDetailPage.tsx | 850 +++++++++++++----- frontend/src/types.ts | 44 +- 22 files changed, 1290 insertions(+), 245 deletions(-) create mode 100644 backend/app/controllers/theatre_skip_events_controller.rb create mode 100644 backend/app/models/theatre_skip_event.rb create mode 100644 backend/app/models/theatre_skip_event_tag.rb create mode 100644 backend/app/models/theatre_skip_event_voter.rb create mode 100644 backend/app/models/theatre_skip_vote.rb create mode 100644 backend/app/services/theatre_post_advancer.rb create mode 100644 backend/app/services/theatre_post_selector.rb create mode 100644 backend/app/services/theatre_skip_finalizer.rb create mode 100644 backend/db/migrate/20260606000000_create_theatre_skip_votes_and_events.rb create mode 100644 backend/spec/requests/theatre_programmes_spec.rb diff --git a/backend/app/controllers/theatre_programmes_controller.rb b/backend/app/controllers/theatre_programmes_controller.rb index 8aadcd6..b8b9dd5 100644 --- a/backend/app/controllers/theatre_programmes_controller.rb +++ b/backend/app/controllers/theatre_programmes_controller.rb @@ -12,6 +12,8 @@ class TheatreProgrammesController < ApplicationController .order(position: :desc).limit(100) .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 diff --git a/backend/app/controllers/theatre_skip_events_controller.rb b/backend/app/controllers/theatre_skip_events_controller.rb new file mode 100644 index 0000000..4ee35da --- /dev/null +++ b/backend/app/controllers/theatre_skip_events_controller.rb @@ -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 diff --git a/backend/app/controllers/theatres_controller.rb b/backend/app/controllers/theatres_controller.rb index 3b74718..8c864e2 100644 --- a/backend/app/controllers/theatres_controller.rb +++ b/backend/app/controllers/theatres_controller.rb @@ -31,9 +31,7 @@ class TheatresController < ApplicationController post_started_at = theatre.current_post_started_at end - render json: { - host_flg:, post_id:, post_started_at:, - watching_users: theatre.watching_users.as_json(only: [:id, :name]) } + render json: theatre_info_json(theatre, host_flg:, post_id:, post_started_at:) end def next_post @@ -44,14 +42,95 @@ class TheatresController < ApplicationController return head :forbidden if theatre.host_user != current_user ApplicationRecord.transaction do - post = Post.where("url LIKE '%nicovideo.jp%'") - .order('RAND()') - .first - theatre.update!(current_post: post, current_post_started_at: Time.current) - position = (theatre.programmes.maximum(:position) || 0) + 1 - theatre.programmes.create!(position:, post:) + theatre.lock! + TheatrePostAdvancer.call(theatre:) end head :no_content 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 diff --git a/backend/app/models/theatre.rb b/backend/app/models/theatre.rb index 3fbdc6a..7912fe3 100644 --- a/backend/app/models/theatre.rb +++ b/backend/app/models/theatre.rb @@ -8,6 +8,8 @@ class Theatre < ApplicationRecord has_many :watching_users, through: :active_theatre_watching_users, source: :user 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 :current_post, class_name: 'Post', optional: true diff --git a/backend/app/models/theatre_skip_event.rb b/backend/app/models/theatre_skip_event.rb new file mode 100644 index 0000000..80936ed --- /dev/null +++ b/backend/app/models/theatre_skip_event.rb @@ -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 diff --git a/backend/app/models/theatre_skip_event_tag.rb b/backend/app/models/theatre_skip_event_tag.rb new file mode 100644 index 0000000..e9ce248 --- /dev/null +++ b/backend/app/models/theatre_skip_event_tag.rb @@ -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 diff --git a/backend/app/models/theatre_skip_event_voter.rb b/backend/app/models/theatre_skip_event_voter.rb new file mode 100644 index 0000000..3db5b71 --- /dev/null +++ b/backend/app/models/theatre_skip_event_voter.rb @@ -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 diff --git a/backend/app/models/theatre_skip_vote.rb b/backend/app/models/theatre_skip_vote.rb new file mode 100644 index 0000000..ecba421 --- /dev/null +++ b/backend/app/models/theatre_skip_vote.rb @@ -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 diff --git a/backend/app/services/theatre_post_advancer.rb b/backend/app/services/theatre_post_advancer.rb new file mode 100644 index 0000000..c7d9e6c --- /dev/null +++ b/backend/app/services/theatre_post_advancer.rb @@ -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 diff --git a/backend/app/services/theatre_post_selector.rb b/backend/app/services/theatre_post_selector.rb new file mode 100644 index 0000000..bbff14b --- /dev/null +++ b/backend/app/services/theatre_post_selector.rb @@ -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 diff --git a/backend/app/services/theatre_skip_finalizer.rb b/backend/app/services/theatre_skip_finalizer.rb new file mode 100644 index 0000000..b098df1 --- /dev/null +++ b/backend/app/services/theatre_skip_finalizer.rb @@ -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 diff --git a/backend/config/routes.rb b/backend/config/routes.rb index dfe2f92..ab9fdc3 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -85,10 +85,14 @@ Rails.application.routes.draw do member do put :watching patch :next_post + put :skip_vote + delete :skip_vote, action: :unskip_vote + get :post_selection_weights end resources :comments, controller: :theatre_comments, only: [:index, :create, :destroy] resources :programmes, controller: :theatre_programmes, only: [:index] + resources :skip_events, controller: :theatre_skip_events, only: [:index] end resources :materials, only: [:index, :show, :create, :update, :destroy] diff --git a/backend/db/migrate/20260606000000_create_theatre_skip_votes_and_events.rb b/backend/db/migrate/20260606000000_create_theatre_skip_votes_and_events.rb new file mode 100644 index 0000000..8361d6a --- /dev/null +++ b/backend/db/migrate/20260606000000_create_theatre_skip_votes_and_events.rb @@ -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 diff --git a/backend/db/schema.rb b/backend/db/schema.rb index b5e2118..ad3325b 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # 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| t.string "name", 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" 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| t.bigint "post_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 "updated_at", 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 ["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" 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" 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| t.bigint "theatre_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_similarities", "posts" 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", "tags" 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_programmes", "posts" 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", "users" add_foreign_key "theatres", "posts", column: "current_post_id" diff --git a/backend/spec/requests/theatre_programmes_spec.rb b/backend/spec/requests/theatre_programmes_spec.rb new file mode 100644 index 0000000..529b583 --- /dev/null +++ b/backend/spec/requests/theatre_programmes_spec.rb @@ -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 diff --git a/backend/spec/requests/theatres_spec.rb b/backend/spec/requests/theatres_spec.rb index 45a4b85..16d0de7 100644 --- a/backend/spec/requests/theatres_spec.rb +++ b/backend/spec/requests/theatres_spec.rb @@ -14,10 +14,17 @@ RSpec.describe 'Theatres API', type: :request do let(:member) { create(:user, :member, name: 'member user') } let(:other_user) { create(:user, :member, name: 'other user') } - let!(:youtube_post) do + let!(:niconico_post) do Post.create!( - title: 'youtube post', - url: 'https://www.youtube.com/watch?v=spec123' + title: 'niconico post', + 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 @@ -120,7 +127,8 @@ RSpec.describe 'Theatres API', type: :request do expect(json).to include( 'host_flg' => true, 'post_id' => nil, - 'post_started_at' => nil + 'post_started_at' => nil, + 'post_elapsed_ms' => nil ) expect(json.fetch('watching_users')).to contain_exactly( @@ -177,7 +185,8 @@ RSpec.describe 'Theatres API', type: :request do expect(json).to include( 'host_flg' => false, 'post_id' => nil, - 'post_started_at' => nil + 'post_started_at' => nil, + 'post_elapsed_ms' => nil ) expect(json.fetch('watching_users')).to contain_exactly( @@ -204,7 +213,7 @@ RSpec.describe 'Theatres API', type: :request do ) theatre.update!( host_user: other_user, - current_post: youtube_post, + current_post: niconico_post, current_post_started_at: started_at ) 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(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'])) .to be_within(1.second).of(started_at) + expect(json['post_elapsed_ms']) + .to be_within(1_000).of(120_000) 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 expect { do_request } .to change { theatre.reload.current_post_id } - .from(nil).to(youtube_post.id) 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) .to be_within(1.second).of(Time.current) + expect(theatre.programmes.count).to eq(1) end end context 'when current user is host and no eligible post exists' do before do - youtube_post.destroy! + niconico_post.destroy! + second_niconico_post.destroy! theatre.update!( host_user: member, current_post: other_post, @@ -299,9 +313,117 @@ RSpec.describe 'Theatres API', type: :request do theatre.reload expect(theatre.current_post_id).to be_nil - expect(theatre.current_post_started_at) - .to be_within(1.second).of(Time.current) + expect(theatre.current_post_started_at).to be_nil 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 diff --git a/frontend/src/components/NicoViewer.tsx b/frontend/src/components/NicoViewer.tsx index a1d0390..ed65823 100644 --- a/frontend/src/components/NicoViewer.tsx +++ b/frontend/src/components/NicoViewer.tsx @@ -37,11 +37,12 @@ type Props = { height: number style?: CSSProperties onLoadComplete?: (info: NiconicoVideoInfo) => void - onMetadataChange?: (meta: NiconicoMetadata) => void } + onMetadataChange?: (meta: NiconicoMetadata) => void + onError?: (data: unknown) => void } export default forwardRef ((props: Props, ref: ForwardedRef) => { - const { id, width, height, style = { }, onLoadComplete, onMetadataChange } = props + const { id, width, height, style = { }, onLoadComplete, onMetadataChange, onError } = props const iframeRef = useRef (null) const playerId = useMemo (() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, [id]) @@ -173,13 +174,16 @@ export default forwardRef ((props: Props, ref: ForwardedRef removeEventListener ('message', onMessage) - }, [onLoadComplete, onMetadataChange, playerId]) + }, [onError, onLoadComplete, onMetadataChange, playerId]) useLayoutEffect (() => { if (!(fullScreen)) diff --git a/frontend/src/components/PostEmbed.tsx b/frontend/src/components/PostEmbed.tsx index 8ae3220..6c04f48 100644 --- a/frontend/src/components/PostEmbed.tsx +++ b/frontend/src/components/PostEmbed.tsx @@ -13,10 +13,11 @@ type Props = { ref?: RefObject post: Post onLoadComplete?: (info: NiconicoVideoInfo) => void - onMetadataChange?: (meta: NiconicoMetadata) => void } + onMetadataChange?: (meta: NiconicoMetadata) => void + onError?: (data: unknown) => void } -const PostEmbed: FC = ({ ref, post, onLoadComplete, onMetadataChange }) => { +const PostEmbed: FC = ({ ref, post, onLoadComplete, onMetadataChange, onError }) => { const dialogue = useDialogue () const [framed, setFramed] = useState (false) @@ -39,7 +40,8 @@ const PostEmbed: FC = ({ ref, post, onLoadComplete, onMetadataChange }) = width={640} height={360} onLoadComplete={onLoadComplete} - onMetadataChange={onMetadataChange}/>) + onMetadataChange={onMetadataChange} + onError={onError}/>) } case 'twitter.com': diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 12235fe..4a7e20b 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -128,8 +128,12 @@ const TopNav: FC = ({ user }) => { const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname }) 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 = visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to)) + const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40) const prevActiveIdxRef = useRef (activeIdx) @@ -240,9 +244,9 @@ const TopNav: FC = ({ user }) => {