hummingbird-me/kitsu-server

View on GitHub
app/models/library_entry.rb

Summary

Maintainability
A
1 hr
Test Coverage
B
86%
# frozen_string_literal: true

class LibraryEntry < ApplicationRecord
  VALID_RATINGS = (2..20).to_a.freeze
  MEDIA_ASSOCIATIONS = %i[anime manga drama].freeze

  belongs_to :user
  belongs_to :media, polymorphic: true
  belongs_to :anime, optional: true
  belongs_to :manga, optional: true
  belongs_to :drama, optional: true
  has_one :review, dependent: :destroy
  has_one :media_reaction, dependent: :destroy
  has_many :library_events, dependent: :destroy

  scope :sfw, -> { where(nsfw: false) }
  scope :by_kind, ->(*kinds) do
    t = arel_table
    columns = kinds.map { |k| t[:"#{k}_id"] }
    scope = columns.shift.not_eq(nil)
    columns.each do |col|
      scope = scope.or(col.not_eq(nil))
    end
    where(scope)
  end
  scope :privacy, ->(privacy) { where(private: !(privacy == :public)) }
  scope :visible_for, ->(user) {
    scope = user && !user.sfw_filter? ? all : sfw

    return scope.privacy(:public) unless user
    return scope if user.permissions.admin?

    scope.privacy(:public).or(
      where(user_id: user).privacy(:private)
    )
  }

  enum status: {
    current: 1,
    planned: 2,
    completed: 3,
    on_hold: 4,
    dropped: 5
  }
  enum reaction_skipped: {
    unskipped: 0,
    skipped: 1,
    ignored: 2
  }
  attr_accessor :imported

  validates :user, :status, :progress, :reconsume_count, presence: true
  validates :media, polymorphism: { type: Media }, presence: true
  validates :anime_id, uniqueness: { scope: :user_id }, allow_nil: true
  validates :manga_id, uniqueness: { scope: :user_id }, allow_nil: true
  validates :drama_id, uniqueness: { scope: :user_id }, allow_nil: true
  validates :progress, numericality: { greater_than_or_equal_to: 0 }
  validates :reconsume_count, numericality: { greater_than_or_equal_to: 0 }
  validates :rating, numericality: {
    greater_than_or_equal_to: 2,
    less_than_or_equal_to: 20
  }, allow_blank: true
  validates :reconsume_count, numericality: {
    less_than_or_equal_to: 50,
    message: 'just... go outside'
  }
  validate :progress_limit
  validate :one_media_present

  counter_culture :user, column_name: ->(le) { 'ratings_count' if le.rating },
    execute_after_commit: true
  scope :rated, -> { where.not(rating: nil) }
  scope :following, ->(user) do
    user_id = user.respond_to?(:id) ? user.id : user
    user_id = sanitize(user_id.to_i) # Juuuuuust to be safe
    follows = Follow.arel_table
    sql = follows.where(follows[:follower_id].eq(user_id)).project(:followed_id)
    where("user_id IN (#{sql.to_sql})")
  end
  scope :completed_at_least_once, -> { completed.or(where('reconsume_count > ?', 0)) }

  def completed_at_least_once?
    completed? || reconsume_count&.positive?
  end

  def progress_limit
    return unless progress
    progress_cap = media&.progress_limit
    default_cap = [media&.default_progress_limit, 50].compact.max

    if progress_cap&.nonzero?
      errors.add(:progress, 'cannot exceed length of media') if progress > progress_cap
    elsif default_cap && progress > default_cap
      errors.add(:progress, 'is rather unreasonably high')
    end
  end

  def one_media_present
    media_present = MEDIA_ASSOCIATIONS.select { |col| send(col).present? }
    return if media_present.count == 1
    media_present.each do |col|
      errors.add(col, 'must have exactly one media present')
    end
  end

  def unit
    media.unit(progress)
  end

  def next_unit
    media.unit(progress + 1)
  end

  def kind
    if anime.present? then :anime
    elsif manga.present? then :manga
    elsif drama.present? then :drama
    end
  end

  def recalculate_time_spent!
    new_time_spent = 0
    new_time_spent += media.episodes.for_progress(progress).sum(:length)
    new_time_spent += media.total_length * reconsume_count if media.total_length
    update(time_spent: new_time_spent)
  end

  before_validation do
    # TEMPORARY: If media is set, copy it to kind_id, otherwise if kind_id is
    # set, copy it to media!
    if kind && send(kind).present?
      self.media = send(kind)
    else
      kind = media_type&.underscore
      send(:"#{kind}=", media) if kind
    end

    self.nsfw = media.nsfw? if media_id_changed? && media.present?
    true
  end

  before_destroy do
    review&.update_attribute(:library_entry_id, nil)
  end

  before_save do
    # When progress equals total episodes
    self.status = :completed if !status_changed? && progress == media&.progress_limit

    if status_changed? && completed? && media&.progress_limit
      # update progress to the cap
      self.progress = media.progress_limit
    end

    unless imported
      self.progressed_at = Time.now if status_changed? || progress_changed?

      if status_changed?
        self.started_at ||= Time.now if current?
        self.started_at ||= Time.now if completed?
        self.finished_at ||= Time.now if completed?
      end

      self.status = :current if progress_changed? && !status_changed?
    end
  end

  after_save do
    # Disable activities and trending vote on import
    unless imported || private?
      media.trending_vote(user, 0.5) if saved_change_to_progress?
      media.trending_vote(user, 1.0) if saved_change_to_status?
    end

    if saved_change_to_rating?
      media.transaction do
        media.decrement_rating_frequency(rating_before_last_save)
        media.increment_rating_frequency(rating)
      end
      user.update_feed_completed!
      user.update_profile_completed!
    end

    if saved_change_to_progress?
      guess = [(progress + 1), media.default_progress_limit].min
      media.update_unit_count_guess(guess)
    end
  end

  LibraryTimeSpentCallbacks.hook(self)
  LibraryStatCallbacks.hook(self)
  LibraryEventCallbacks.hook(self)

  after_commit on: :destroy do
    MediaFollowService.new(user, media).destroy
  end

  after_commit on: :create do
    MediaFollowService.new(user, media).create
  end

  after_commit(on: :create, if: :sync_to_mal?) do
    LibraryEntryLog.create_for(:create, self, myanimelist_linked_account)
    ListSync::UpdateWorker.perform_async(myanimelist_linked_account.id, id)
  end

  after_commit(on: :update, if: :sync_to_mal?) do
    LibraryEntryLog.create_for(:update, self, myanimelist_linked_account)
    ListSync::UpdateWorker.perform_async(myanimelist_linked_account.id, id)
  end

  after_commit(on: :destroy, if: :sync_to_mal?) do
    LibraryEntryLog.create_for(:destroy, self, myanimelist_linked_account)
    ListSync::DestroyWorker.perform_async(myanimelist_linked_account.id,
      media_type, media_id)
  end

  def sync_to_mal?
    return unless media_type.in? %w[Anime Manga]
    return false if imported

    myanimelist_linked_account.present?
  end

  def myanimelist_linked_account
    @mal_linked_account ||= User.find(user_id).linked_accounts.find_by(
      sync_to: true,
      type: 'LinkedAccount::MyAnimeList'
    )
  end
end