9 Commits

Author SHA1 Message Date
Dylan Knutson
bc4143ae12 migrate fa posts to aux table 2025-07-26 05:39:32 +00:00
Dylan Knutson
ca4729f7d1 migrate Domain::Post::FaPost to aux table 2025-07-26 00:54:03 +00:00
Dylan Knutson
3d2599a4ab Create task task-74 2025-07-26 00:16:45 +00:00
Dylan Knutson
072bbb849e Create task task-73 2025-07-25 20:00:32 +00:00
Dylan Knutson
56ed78faaf show pending messages on posts 2025-07-25 19:15:58 +00:00
Dylan Knutson
bd6246d29a send pre-thumbnailed images when type is content-container 2025-07-25 05:12:32 +00:00
Dylan Knutson
5aeee4fe14 detect hle links in log lines 2025-07-25 04:36:10 +00:00
Dylan Knutson
f11a5782e1 fuzzysearch handle no sha256 for file 2025-07-25 04:20:40 +00:00
Dylan Knutson
dca8ba4566 cache fuzzysearch count 2025-07-25 03:46:19 +00:00
31 changed files with 510 additions and 174 deletions

View File

@@ -1,4 +1,4 @@
rails: RAILS_ENV=development HTTP_PORT=3000 TARGET_PORT=3003 rdbg --command --nonstop --open -- thrust ./bin/rails server -p 3003
rails: RAILS_ENV=development HTTP_PORT=3001 rdbg --command --nonstop --open -- thrust ./bin/rails server
wp-client: RAILS_ENV=development HMR=true ./bin/webpacker-dev-server
wp-server: RAILS_ENV=development HMR=true SERVER_BUNDLE_ONLY=yes ./bin/webpacker --watch
css: tailwindcss -c ./config/tailwind.config.js -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/tailwind.css --watch

View File

@@ -2,4 +2,4 @@ rails: RAILS_ENV=staging HTTP_PORT=3001 bundle exec thrust ./bin/rails server
wp-client: RAILS_ENV=development HMR=true ./bin/webpacker-dev-server
wp-server: RAILS_ENV=development HMR=true SERVER_BUNDLE_ONLY=yes ./bin/webpacker --watch
css: RAILS_ENV=development yarn "build:css[debug]" --watch
prometheus_exporter: RAILS_ENV=staging bundle exec prometheus_exporter --bind 0.0.0.0 --prefix redux_ --label '{"environment": "staging"}'
prometheus-exporter: RAILS_ENV=staging bundle exec prometheus_exporter --bind 0.0.0.0 --prefix redux_ --label '{"environment": "staging"}'

View File

@@ -51,6 +51,20 @@ class BlobEntriesController < ApplicationController
file_ext = "gif"
end
# content-container may be pre-thumbnailed, see if the file is on the disk
if thumb == "content-container" && file_ext == "jpeg"
thumbnail_path =
Domain::PostFile::Thumbnail.absolute_file_path(
sha256,
"content_container",
0,
)
if File.exist?(thumbnail_path)
send_file(thumbnail_path, type: "image/jpeg", disposition: "inline")
return true
end
end
width, height = thumb_params
filename = "thumb-#{sha256}-#{thumb}.#{file_ext}"
cache_key = "vips:#{filename}"

View File

@@ -77,8 +77,9 @@ class Domain::PostsController < DomainController
@user = T.must(@user)
authorize @user
@posts = @user.faved_posts.with_user_post_fav
# raise @posts.to_sql
@posts = @user.faved_posts
@post_favs =
Domain::UserPostFav.where(user: @user, post: @posts).index_by(&:post_id)
# Apply pagination through posts_relation
@posts = posts_relation(@posts, skip_ordering: true)

View File

@@ -75,10 +75,12 @@ module Domain::PostsHelper
file
end
sig { params(post: Domain::Post).returns(T.nilable(String)) }
sig { params(post: Domain::Post).returns(T.any(T.nilable(String), Symbol)) }
def gallery_file_info_for_post(post)
return :post_pending if post.pending_scan?
file = post.primary_file_for_view
return nil unless file.present?
return :file_pending if file.state_pending?
return nil unless file.state_ok?
return nil unless file.log_entry_id.present?
content_type = file.log_entry&.content_type || ""

View File

@@ -9,9 +9,9 @@ module GoodJobHelper
class AnsiSegment < T::Struct
include T::Struct::ActsAsComparable
const :text, String
const :class_names, T::Array[String]
const :url, T.nilable(String)
prop :text, String
prop :class_names, T::Array[String], default: []
prop :url, T.nilable(String), default: nil
end
# ANSI escape code pattern
@@ -20,7 +20,7 @@ module GoodJobHelper
sig { params(text: String).returns(T::Array[AnsiSegment]) }
def parse_ansi(text)
segments = []
segments = T.let([], T::Array[AnsiSegment])
current_classes = T::Array[String].new
# Split the text into parts based on ANSI codes
@@ -48,24 +48,34 @@ module GoodJobHelper
end
end
segments.each_with_index do |s0, idx|
s1 = segments[idx + 1] || next
if s0.text == "[hle " && s1.text.match(/\d+/)
segments[idx + 1] = AnsiSegment.new(
text: s1.text,
class_names: s1.class_names,
url: Rails.application.routes.url_helpers.log_entry_path(s1.text),
)
end
end
# go through segments and detect UUIDs, splitting the segment at the uuid
# and adding them to the segments array. Should result in a <before>, <uuid>,
# <after> tuple.
segments.flat_map do |segment|
if segment.text.match?(UUID_REGEX)
idx = segment.text.index(UUID_REGEX)
if (idx = segment.text.index(UUID_REGEX))
[
AnsiSegment.new(
text: segment.text[0...idx],
text: segment.text[0...idx] || "",
class_names: segment.class_names,
),
AnsiSegment.new(
text: segment.text[idx...idx + 36],
text: segment.text[idx...idx + 36] || "",
class_names: ["log-uuid"],
url: "/jobs/jobs/#{segment.text[idx...idx + 36]}",
),
AnsiSegment.new(
text: segment.text[idx + 36..],
text: segment.text[idx + 36..] || "",
class_names: segment.class_names,
),
]

View File

@@ -45,7 +45,7 @@ class Domain::Fa::Job::ScanFuzzysearchJob < Domain::Fa::Job::Base
post.fuzzysearch_checked_at = Time.now
if response.is_a?(HttpLogEntry)
post.fuzzysearch_entry = response
logger.error("fuzzysearch query failed")
logger.error("fuzzysearch query failed or returned no results")
return
end

View File

@@ -6,11 +6,18 @@ class Scraper::ClientFactory
@gallery_dl_clients = Concurrent::ThreadLocalVar.new(nil)
# for testing only
sig { params(mock: T.nilable(Scraper::HttpClient)).void }
def self.http_client_mock=(mock)
raise unless Rails.env.test?
@http_client_mock = mock
end
sig { returns(T.nilable(Scraper::HttpClient)) }
def self.http_client_mock
raise unless Rails.env.test?
@http_client_mock
end
def self.gallery_dl_client_mock=(mock)
raise unless Rails.env.test?
@gallery_dl_client_mock = mock

View File

@@ -23,7 +23,7 @@ class Scraper::FuzzysearchApiClient
const :artist_name, String
const :deleted, T::Boolean
const :file_url, String
const :file_sha256, String
const :file_sha256, T.nilable(String)
const :tags, T::Array[String]
end
@@ -33,12 +33,20 @@ class Scraper::FuzzysearchApiClient
url.query_values = { search: fa_id.to_s }
response = @http_client.get(url)
return :rate_limit_exceeded if response.status_code == 429
if response.status_code == 429
logger.error(
format_tags(
make_tag("status_code", response.status_code),
"fuzzysearch rate limit exceeded",
),
)
return :rate_limit_exceeded
end
if response.status_code != 200
logger.error(
format_tags(
make_tag("status_code", response.status_code),
make_tag("uri", url.to_s),
"fuzzysearch query failed",
),
)

View File

@@ -3,6 +3,9 @@ class Tasks::Fa::QueryMissingPostsFromFuzzysearch < EnqueueJobBase
extend T::Sig
include Domain::Fa::HasCountFailedInQueue
PROGRESS_KEY = "fa-query-missing-posts-from-fuzzysearch"
COUNT_KEY = "fa-query-missing-posts-from-fuzzysearch-count"
sig { params(start_at: T.nilable(String), kwargs: T.untyped).void }
def initialize(start_at: nil, **kwargs)
super(**kwargs)
@@ -11,7 +14,7 @@ class Tasks::Fa::QueryMissingPostsFromFuzzysearch < EnqueueJobBase
sig { override.returns(String) }
def progress_key
"fa-query-missing-posts-from-fuzzysearch"
PROGRESS_KEY
end
sig { override.void }
@@ -31,7 +34,9 @@ class Tasks::Fa::QueryMissingPostsFromFuzzysearch < EnqueueJobBase
greatest_post_fa_id = query.first&.fa_id
log("counting posts...")
count = query.count
count = GlobalState.get(COUNT_KEY)&.to_i || query.count
GlobalState.set(COUNT_KEY, count.to_s)
puts "number of posts to process: #{count}"
pb = create_progress_bar(count)
@@ -62,6 +67,7 @@ class Tasks::Fa::QueryMissingPostsFromFuzzysearch < EnqueueJobBase
last_processed_fa_id = posts.map(&:fa_id).compact.min
if last_processed_fa_id
save_progress(last_processed_fa_id.to_s)
GlobalState.set(COUNT_KEY, (count - pb.progress).to_s)
greatest_post_fa_id = last_processed_fa_id - 1
else
break
@@ -76,6 +82,6 @@ class Tasks::Fa::QueryMissingPostsFromFuzzysearch < EnqueueJobBase
sig { override.returns(Integer) }
def queue_size
count_failed_in_queue("fuzzysearch")
count_failed_in_queue(%w[fuzzysearch fur_archiver])
end
end

View File

@@ -97,33 +97,6 @@ class Domain::Post < ReduxApplicationRecord
source: :user
end
# special purpose association for preloading user_post_favs
belongs_to :user_post_fav,
class_name: "::Domain::UserPostFav",
foreign_key: %i[user_id id],
primary_key: %i[user_id post_id],
optional: true
scope :with_user_post_fav,
-> do
self
.reorder("")
.includes(:user_post_fav)
.select("#{Domain::Post.table_name}.*")
.select("#{Domain::UserPostFav.table_name}.user_id as user_id")
.select(
"(#{Domain::UserPostFav.table_name}.json_attributes->>'fav_id')::integer as fav_id",
)
.order(
# if there is no fav_id, use post_order_attribute to put the post after
# the post with the next highest post_order_attribute
# otherwise sort by fav_id, then post_order_attribute
Arel.sql(
"fav_id DESC, (#{Domain::Post.table_name}.json_attributes->>'#{self.post_order_attribute}')::integer DESC",
),
)
end
has_many :user_post_favs,
class_name: "::Domain::UserPostFav",
inverse_of: :post,
@@ -162,6 +135,11 @@ class Domain::Post < ReduxApplicationRecord
:id
end
sig { overridable.returns(T::Boolean) }
def pending_scan?
false
end
sig { abstract.returns(T.nilable(String)) }
def title
end

View File

@@ -1,34 +1,6 @@
# typed: strict
class Domain::Post::FaPost < Domain::Post
include AttrJsonRecordAliases
attr_json :title, :string
attr_json :state, :string
attr_json :fa_id, :integer
attr_json :category, :string
attr_json :theme, :string
attr_json :species, :string
attr_json :gender, :string
attr_json :description, :string
attr_json :keywords, :string, array: true, default: []
attr_json :num_favorites, :integer
attr_json :num_comments, :integer
attr_json :num_views, :integer
attr_json :scanned_at, ActiveModelUtcTimeValue.new
attr_json :scan_file_error, :string
attr_json :last_user_page_id, :integer
attr_json :first_browse_page_id, :integer
attr_json :first_gallery_page_id, :integer
attr_json :first_seen_entry_id, :integer
attr_json :fuzzysearch_checked_at, ActiveModelUtcTimeValue.new
attr_json :fuzzysearch_json, ActiveModel::Type::Value.new
attr_json :fuzzysearch_entry_id, :integer
# TODO - convert `file` to Domain::PostFile::FaPostFile and
# move this to Domain::PostFile::FaPostFile
attr_json :tried_from_fur_archiver, :boolean, default: false
attr_json :tried_from_tor, :boolean, default: false
aux_table :fa, allow_redefining: :title
belongs_to :last_user_page, class_name: "::HttpLogEntry", optional: true
belongs_to :first_browse_page, class_name: "::HttpLogEntry", optional: true
@@ -40,7 +12,7 @@ class Domain::Post::FaPost < Domain::Post
has_single_creator! Domain::User::FaUser
has_faving_users! Domain::User::FaUser
after_initialize { self.state ||= "ok" }
after_initialize { self.state ||= "ok" if new_record? }
enum :state,
{
@@ -73,11 +45,6 @@ class Domain::Post::FaPost < Domain::Post
Domain::DomainType::Fa
end
sig { override.returns(T.nilable(String)) }
def title
super
end
sig { override.returns(T.nilable(Domain::User)) }
def primary_creator_for_view
self.creator
@@ -100,6 +67,11 @@ class Domain::Post::FaPost < Domain::Post
self.file
end
sig { override.returns(T::Boolean) }
def pending_scan?
scanned_at.nil?
end
sig { override.returns(T.nilable(String)) }
def description_html_for_view
description

View File

@@ -72,6 +72,15 @@ class Domain::PostFile::Thumbnail < ReduxApplicationRecord
return nil unless (post_file = self.post_file)
return nil unless (sha256 = post_file.sha256)
sha256_hex = HexUtil.bin2hex(sha256)
self.class.absolute_file_path(sha256_hex, thumb_type, T.must(self.frame))
end
sig do
params(sha256_hex: String, thumb_type: String, frame: Integer).returns(
T.nilable(String),
)
end
def self.absolute_file_path(sha256_hex, thumb_type, frame)
[
THUMB_ROOT_DIR,
thumb_type.to_s,

View File

@@ -13,10 +13,16 @@
alt: post.title_for_view %>
<% end %>
<% elsif file_info = gallery_file_info_for_post(post) %>
<div class="flex items-center border border-slate-200 rounded-md px-3 py-2 bg-slate-100 shadow-sm">
<i class="fas fa-file-alt text-slate-400 mr-2"></i>
<span class="text-sm text-slate-500 italic"><%= file_info %>, <%= gallery_file_size_for_post(post) %></span>
</div>
<% if file_info == :post_pending %>
<span class="text-sm text-slate-500 italic">Post pending scan</span>
<% elsif file_info == :file_pending %>
<span class="text-sm text-slate-500 italic">File pending download</span>
<% else %>
<div class="flex items-center border border-slate-200 rounded-md px-3 py-2 bg-slate-100 shadow-sm">
<i class="fas fa-file-alt text-slate-400 mr-2"></i>
<span class="text-sm text-slate-500 italic"><%= file_info %>, <%= gallery_file_size_for_post(post) %></span>
</div>
<% end %>
<% else %>
<span class="text-sm text-slate-500 italic">No file available</span>
<% end %>
@@ -46,7 +52,8 @@
<% end %>
<% end %>
<span class="flex-grow text-right">
<% if (faved_at = post.user_post_fav&.faved_at) && (time = faved_at.time) %>
<% user_post_fav = @post_favs && @post_favs[post.id] %>
<% if (faved_at = user_post_fav&.faved_at) && (time = faved_at.time) %>
<span class="flex items-center gap-1 justify-end">
<span
title="<%= time&.in_time_zone&.strftime("%Y-%m-%d %I:%M:%S %p %Z") %>"

View File

@@ -1,6 +1,7 @@
<% if policy(post).view_file? %>
<% file = post.primary_file_for_view %>
<section>
<% if (log_entry = post.primary_file_for_view&.log_entry) %>
<% if file.present? && (log_entry = file.log_entry) %>
<% if log_entry.status_code == 200 %>
<%= render partial: "log_entries/content_container",
locals: {
@@ -19,6 +20,13 @@
</div>
</section>
<% end %>
<% elsif file.present? && file.state_pending? %>
<section class="flex grow justify-center text-slate-500">
<div>
<i class="fa-solid fa-file-arrow-down"></i>
File pending download
</div>
</section>
<% else %>
<section class="flex grow justify-center overflow-clip">
<div class="text-slate-500">

View File

@@ -1,5 +1,6 @@
<%# nasty hack, otherwise postgres uses a bad query plan %>
<% fav_posts = user.faved_posts.with_user_post_fav.includes(:creator).limit(5) %>
<% fav_posts = user.faved_posts.includes(:creator).limit(5) %>
<% post_favs = Domain::UserPostFav.where(user: user, post: fav_posts).index_by(&:post_id) %>
<section class="animated-shadow-sky sky-section">
<h2 class="section-header">
<span class="font-medium text-slate-900">Favorited Posts</span>
@@ -20,7 +21,8 @@
}
) %>
<span class="whitespace-nowrap flex-grow text-right text-slate-500">
<% if (faved_at = post.user_post_fav&.faved_at) && (time = faved_at.time) %>
<% user_post_fav = post_favs[post.id] %>
<% if (faved_at = user_post_fav&.faved_at) && (time = faved_at.time) %>
<%= time_ago_in_words(faved_at.time) %> ago
<% else %>
unknown

View File

@@ -12,7 +12,8 @@
window.MiniProfiler.patchesApplied = true;
</script>
<% end %>
<%= favicon_link_tag "refurrer-logo-icon.png" %>
<link rel="icon" href="data:,">
<%= favicon_link_tag "refurrer-logo-icon.png", type: "image/png" %>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= javascript_pack_tag "application-bundle" %>

View File

@@ -0,0 +1,21 @@
---
id: task-73
title: Position hover popups near mouse cursor
status: To Do
assignee: []
created_date: '2025-07-25'
labels: []
dependencies: []
---
## Description
Currently hover popups for posts and users appear at the boundary of the link element, which can feel disconnected from the user's mouse position. Positioning them closer to the mouse cursor would provide a more intuitive and responsive user experience.
## Acceptance Criteria
- [ ] Post hover popups appear near mouse cursor position
- [ ] User hover popups appear near mouse cursor position
- [ ] Popups maintain proper positioning when mouse moves within link boundaries
- [ ] Popups don't overlap with important UI elements
- [ ] Popups remain accessible and don't go off-screen

View File

@@ -0,0 +1,25 @@
---
id: task-74
title: Add OpenGraph tags for social media sharing
status: To Do
assignee: []
created_date: '2025-07-26'
labels: []
dependencies: []
---
## Description
Currently when pages are shared on social media platforms or messaging apps, they lack rich previews with thumbnails and structured metadata. Adding OpenGraph tags would enable proper social media cards with thumbnails, titles, and descriptions, improving the sharing experience and potentially increasing engagement.
## Acceptance Criteria
- [ ] Post pages include OpenGraph title tag
- [ ] Post pages include OpenGraph description tag
- [ ] Post pages include OpenGraph image tag with post thumbnail
- [ ] User pages include OpenGraph title tag
- [ ] User pages include OpenGraph description tag
- [ ] User pages include OpenGraph image tag with user avatar
- [ ] Site homepage includes OpenGraph tags
- [ ] OpenGraph tags validate using Facebook debugger or similar tools
- [ ] Shared links display rich previews on major platforms (Twitter/X Discord Facebook)

View File

@@ -5,7 +5,7 @@ class MigrateE621PostDataToAux < ActiveRecord::Migration[7.2]
sig { void }
def up
execute <<~SQL
INSERT INTO domain_posts_e621_aux (
INSERT INTO domain_posts_e621_aux (
base_table_id,
state,
e621_id,

View File

@@ -0,0 +1,77 @@
# typed: strict
# frozen_string_literal: true
class MigrateFaPostsToAux < ActiveRecord::Migration[7.2]
sig { void }
def up
cols = [
[:state, :string, {}],
[:title, :string, {}],
[:fa_id, :integer, {}],
[:category, :string, {}],
[:theme, :string, {}],
[:species, :string, {}],
[:gender, :string, {}],
[:description, :string, {}],
[:keywords, :jsonb, { default: [] }],
[:num_favorites, :integer, {}],
[:num_comments, :integer, {}],
[:num_views, :integer, {}],
[:scanned_at, :timestamp, {}],
[:scan_file_error, :string, {}],
[:last_user_page_id, :integer, {}],
[:first_browse_page_id, :integer, {}],
[:first_gallery_page_id, :integer, {}],
[:first_seen_entry_id, :integer, {}],
[:fuzzysearch_checked_at, :timestamp, {}],
[:fuzzysearch_json, :jsonb, {}],
[:fuzzysearch_entry_id, :integer, {}],
[:tried_from_fur_archiver, :boolean, { default: false }],
[:tried_from_tor, :boolean, { default: false }],
]
create_aux_table :domain_posts, :fa do |t|
cols.each { |name, type, opts| t.send(type, name, **opts) }
end
col_names =
cols.map do |name, type, opts|
type == :references ? "#{name}_id" : "#{name}"
end
col_selects =
cols.map do |name, type, opts|
if type == :references
"(json_attributes->>'#{name}_id')::integer as #{name}_id"
else
type =
case type
when :string
"text"
else
type.to_s
end
"(json_attributes->>'#{name}')::#{type} as #{name}"
end
end
sql = <<~SQL
INSERT INTO domain_posts_fa_aux (
base_table_id,
#{col_names.join(",\n ")}
)
SELECT
id as base_table_id,
#{col_selects.join(",\n ")}
FROM domain_posts
WHERE type = 'Domain::Post::FaPost'
SQL
execute sql
end
sig { void }
def down
drop_table :domain_posts_fa_aux
end
end

View File

@@ -0,0 +1,5 @@
class CreateIndexOnFaId < ActiveRecord::Migration[7.2]
def change
add_index :domain_posts_fa_aux, :fa_id
end
end

View File

@@ -0,0 +1,17 @@
class MakeIndexUniqueOnFaId < ActiveRecord::Migration[7.2]
def change
# make the exisitng index a unique index
add_index :domain_posts_fa_aux,
:fa_id,
name: "index_domain_posts_fa_aux_on_fa_id_unique",
unique: true
remove_index :domain_posts_fa_aux,
:fa_id,
name: "index_domain_posts_fa_aux_on_fa_id"
rename_index :domain_posts_fa_aux,
"index_domain_posts_fa_aux_on_fa_id_unique",
"index_domain_posts_fa_aux_on_fa_id"
end
end

View File

@@ -1390,6 +1390,57 @@ CREATE SEQUENCE public.domain_posts_e621_aux_base_table_id_seq
ALTER SEQUENCE public.domain_posts_e621_aux_base_table_id_seq OWNED BY public.domain_posts_e621_aux.base_table_id;
--
-- Name: domain_posts_fa_aux; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.domain_posts_fa_aux (
base_table_id bigint NOT NULL,
state character varying,
title character varying,
fa_id integer,
category character varying,
theme character varying,
species character varying,
gender character varying,
description character varying,
keywords jsonb DEFAULT '[]'::jsonb,
num_favorites integer,
num_comments integer,
num_views integer,
scanned_at timestamp without time zone,
scan_file_error character varying,
last_user_page_id integer,
first_browse_page_id integer,
first_gallery_page_id integer,
first_seen_entry_id integer,
fuzzysearch_checked_at timestamp without time zone,
fuzzysearch_json jsonb,
fuzzysearch_entry_id integer,
tried_from_fur_archiver boolean DEFAULT false,
tried_from_tor boolean DEFAULT false
);
--
-- Name: domain_posts_fa_aux_base_table_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.domain_posts_fa_aux_base_table_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: domain_posts_fa_aux_base_table_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.domain_posts_fa_aux_base_table_id_seq OWNED BY public.domain_posts_fa_aux.base_table_id;
--
-- Name: domain_posts_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
@@ -2820,6 +2871,13 @@ ALTER TABLE ONLY public.domain_posts ALTER COLUMN id SET DEFAULT nextval('public
ALTER TABLE ONLY public.domain_posts_e621_aux ALTER COLUMN base_table_id SET DEFAULT nextval('public.domain_posts_e621_aux_base_table_id_seq'::regclass);
--
-- Name: domain_posts_fa_aux base_table_id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_posts_fa_aux ALTER COLUMN base_table_id SET DEFAULT nextval('public.domain_posts_fa_aux_base_table_id_seq'::regclass);
--
-- Name: domain_twitter_tweets id; Type: DEFAULT; Schema: public; Owner: -
--
@@ -3017,6 +3075,14 @@ ALTER TABLE ONLY public.domain_posts_e621_aux
ADD CONSTRAINT domain_posts_e621_aux_pkey PRIMARY KEY (base_table_id);
--
-- Name: domain_posts_fa_aux domain_posts_fa_aux_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_posts_fa_aux
ADD CONSTRAINT domain_posts_fa_aux_pkey PRIMARY KEY (base_table_id);
--
-- Name: domain_posts domain_posts_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -3960,6 +4026,20 @@ CREATE INDEX index_domain_post_groups_on_type ON public.domain_post_groups USING
CREATE INDEX index_domain_posts_e621_aux_on_base_table_id ON public.domain_posts_e621_aux USING btree (base_table_id);
--
-- Name: index_domain_posts_fa_aux_on_base_table_id; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_domain_posts_fa_aux_on_base_table_id ON public.domain_posts_fa_aux USING btree (base_table_id);
--
-- Name: index_domain_posts_fa_aux_on_fa_id; Type: INDEX; Schema: public; Owner: -
--
CREATE UNIQUE INDEX index_domain_posts_fa_aux_on_fa_id ON public.domain_posts_fa_aux USING btree (fa_id);
--
-- Name: index_domain_posts_on_posted_at; Type: INDEX; Schema: public; Owner: -
--
@@ -5159,6 +5239,14 @@ ALTER TABLE ONLY public.domain_users_e621_aux
ADD CONSTRAINT fk_rails_b5bacbced6 FOREIGN KEY (base_table_id) REFERENCES public.domain_users(id);
--
-- Name: domain_posts_fa_aux fk_rails_be2be2e955; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_posts_fa_aux
ADD CONSTRAINT fk_rails_be2be2e955 FOREIGN KEY (base_table_id) REFERENCES public.domain_posts(id);
--
-- Name: domain_users_inkbunny_aux fk_rails_c2d597dcc4; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -5262,6 +5350,9 @@ ALTER TABLE ONLY public.domain_twitter_tweets
SET search_path TO "$user", public;
INSERT INTO "schema_migrations" (version) VALUES
('20250726051748'),
('20250726051451'),
('20250725192431'),
('20250724213505'),
('20250723194407'),
('20250723193659'),

View File

@@ -14,6 +14,43 @@ RSpec.describe Domain::PostsController, type: :controller do
allow(controller).to receive(:authorize).and_return(true)
end
describe "GET #index" do
it "returns a successful response and renders the index template" do
get :index
expect(response).to be_successful
expect(response).to render_template(:index)
end
context "when post is pending scan" do
let!(:post_pending_scan) do
create(
:domain_post_fa_post,
scanned_at: nil,
title: "Test Post Pending Scan",
)
end
it "displays 'Post pending scan' message" do
get :index
expect(response.body).to include("Post pending scan")
end
end
context "when post file is pending download" do
let!(:post_with_pending_file) do
create(
:domain_post_fa_post,
title: "Test Post with Pending File",
).tap { |post| create(:domain_post_file, post: post, state: "pending") }
end
it "displays 'File pending download' message" do
get :index
expect(response.body).to include("File pending download")
end
end
end
describe "GET #show" do
shared_examples "a post" do
it "returns a successful response and renders the show template" do
@@ -37,6 +74,38 @@ RSpec.describe Domain::PostsController, type: :controller do
let(:post) { create(:domain_post_e621_post) }
it_behaves_like "a post"
end
context "when post file is pending download" do
let(:post) do
create(
:domain_post_fa_post,
title: "Test Post with Pending File",
).tap do |post|
create(
:domain_post_file,
post: post,
state: "pending",
url_str: "https://example.com/image.jpg",
)
end
end
it "displays 'File pending download' message" do
get :show, params: { id: post.to_param }
expect(response.body).to include("File pending download")
end
end
context "when post has no file" do
let(:post) do
create(:domain_post_fa_post, title: "Test Post with No File")
end
it "displays 'No file' message" do
get :show, params: { id: post.to_param }
expect(response.body).to include("No file")
end
end
end
describe "GET #visual_search" do

View File

@@ -1,5 +1,6 @@
# typed: true
class HttpClientMockHelpers
extend T::Sig
include HasColorLogger
extend FactoryBot::Syntax::Methods
extend RSpec::Mocks::ExampleMethods
@@ -41,6 +42,13 @@ class HttpClientMockHelpers
)
end
sig do
params(
http_client_mock: Scraper::HttpClient,
requests: T::Array[T.untyped],
any_order: T::Boolean,
).returns(T::Array[HttpLogEntry])
end
def self.init_http_client_mock(http_client_mock, requests, any_order: false)
if any_order
init_any_order(http_client_mock, requests)
@@ -49,6 +57,19 @@ class HttpClientMockHelpers
end
end
sig do
params(requests: T::Array[T.untyped], any_order: T::Boolean).returns(
T::Array[HttpLogEntry],
)
end
def self.init_with(requests, any_order: false)
if any_order
init_any_order(Scraper::ClientFactory.http_client_mock, requests)
else
init_ordered(Scraper::ClientFactory.http_client_mock, requests)
end
end
def self.init_ordered(http_client_mock, requests)
log_entries = []

View File

@@ -2,33 +2,20 @@
require "rails_helper"
describe Domain::Fa::Job::ScanFuzzysearchJob do
let(:http_client_mock) { instance_double("::Scraper::HttpClient") }
before { Scraper::ClientFactory.http_client_mock = http_client_mock }
let(:client_mock_config) { [] }
let!(:log_entries) do
HttpClientMockHelpers.init_http_client_mock(
http_client_mock,
client_mock_config,
HttpClientMockHelpers.init_with(
[
{
uri:
"https://api-next.fuzzysearch.net/v1/file/furaffinity?search=#{fa_id}",
status_code:,
content_type: "application/json",
contents: File.read("test/fixtures/files/fuzzysearch/#{fa_id}.json"),
},
],
)
end
let(:fuzzysearch_response_51015903) do
JSON.parse(File.read("test/fixtures/files/fuzzysearch/51015903.json"))
end
let(:fuzzysearch_response_21275696) do
JSON.parse(File.read("test/fixtures/files/fuzzysearch/21275696.json"))
end
let(:fuzzysearch_response_53068507) do
JSON.parse(File.read("test/fixtures/files/fuzzysearch/53068507.json"))
end
let(:fuzzysearch_response_61665194) do
JSON.parse(File.read("test/fixtures/files/fuzzysearch/61665194.json"))
end
describe "post was marked removed" do
let(:post) do
create(
@@ -41,17 +28,7 @@ describe Domain::Fa::Job::ScanFuzzysearchJob do
context "and fuzzysearch has post info" do
let(:fa_id) { 51_015_903 }
let(:client_mock_config) do
[
{
uri:
"https://api-next.fuzzysearch.net/v1/file/furaffinity?search=#{fa_id}",
status_code: 200,
content_type: "application/json",
contents: fuzzysearch_response_51015903.to_json,
},
]
end
let(:status_code) { 200 }
it "updates the post" do
perform_now({ post: })
@@ -83,6 +60,14 @@ describe Domain::Fa::Job::ScanFuzzysearchJob do
expect(post.creator.url_name).to eq("crimetxt")
end
it "sets the file sha256" do
perform_now({ post: })
post.reload
expect(post.fuzzysearch_json["sha256"]).to eq(
"d488dabd8eb22398a228fb662eb520bb4daaac3a9ab0dc9be8b8c5e1b9522efb",
)
end
it "enqueues a fur archiver post file job" do
perform_now({ post: })
post.reload
@@ -110,17 +95,7 @@ describe Domain::Fa::Job::ScanFuzzysearchJob do
context "and fuzzysearch has no post info" do
let(:fa_id) { 21_275_696 }
let(:client_mock_config) do
[
{
uri:
"https://api-next.fuzzysearch.net/v1/file/furaffinity?search=#{fa_id}",
status_code: 200,
content_type: "application/json",
contents: fuzzysearch_response_21275696.to_json,
},
]
end
let(:status_code) { 200 }
it "does not set the creator" do
perform_now({ post: })
@@ -137,17 +112,7 @@ describe Domain::Fa::Job::ScanFuzzysearchJob do
context "and the artist name has capitalizations" do
let(:fa_id) { 53_068_507 }
let(:client_mock_config) do
[
{
uri:
"https://api-next.fuzzysearch.net/v1/file/furaffinity?search=#{fa_id}",
status_code: 200,
content_type: "application/json",
contents: fuzzysearch_response_53068507.to_json,
},
]
end
let(:status_code) { 200 }
it "sets the creator" do
perform_now({ post: })
@@ -161,17 +126,7 @@ describe Domain::Fa::Job::ScanFuzzysearchJob do
context "and the post has a story url" do
let(:fa_id) { 61_665_194 }
let(:client_mock_config) do
[
{
uri:
"https://api-next.fuzzysearch.net/v1/file/furaffinity?search=#{fa_id}",
status_code: 200,
content_type: "application/json",
contents: fuzzysearch_response_61665194.to_json,
},
]
end
let(:status_code) { 200 }
it "does not change the post state" do
perform_now({ post: })
@@ -186,7 +141,7 @@ describe Domain::Fa::Job::ScanFuzzysearchJob do
expect(post.creator.url_name).to eq("irontankris")
end
it "updates keywords", quiet: false do
it "updates keywords" do
post.keywords = []
post.save!
perform_now({ post: })
@@ -194,5 +149,23 @@ describe Domain::Fa::Job::ScanFuzzysearchJob do
expect(post.keywords).to include("female", "mlp", "little", "anthro")
end
end
context "the post has no sha256" do
let(:fa_id) { 61_429_615 }
let(:status_code) { 200 }
it "sets the artist" do
perform_now({ post: })
post.reload
expect(post.creator).to be_present
expect(post.creator.url_name).to eq("622000")
end
it "does not set the sha256" do
perform_now({ post: })
post.reload
expect(post.fuzzysearch_json["sha256"]).to be_nil
end
end
end
end

View File

@@ -173,7 +173,7 @@ describe Domain::Fa::Job::ScanPostJob do
expect do
perform_now({ post: post })
post.reload
end.to not_change(post, :scanned_at)
end.to change { post.scanned_at }.by_at_most(1.second)
end
end

View File

@@ -44,17 +44,11 @@ RSpec.configure do |config|
end
end
# this breaks parallel tests because it's not thread safe
# config.before(:all) do
# # safeguard against running this test in a non-test environment
# root_dir =
# File.absolute_path(Rails.application.config_for("blob_file_location"))
# if root_dir.match?(%r{^#{Rails.root}/tmp})
# FileUtils.rm_rf(root_dir)
# else
# raise "blob_file_location is not in the tmp directory"
# end
# end
# Reset http client mock before each test
config.before(:each) do
Scraper::ClientFactory.http_client_mock =
T.unsafe(self).instance_double("::Scraper::HttpClient")
end
# rspec-expectations config goes here. You can use an alternate
# assertion/expectation library such as wrong or the stdlib/minitest

View File

@@ -0,0 +1,18 @@
[
{
"id": 61429615,
"file_id": 1751417221,
"artist": "622000",
"hash": null,
"hash_str": null,
"url": "https://d.furaffinity.net/art/622000/1751417221/1751417221.622000_1000007071.png",
"filename": "1751417221.622000_1000007071.png",
"rating": "general",
"posted_at": "2025-07-02T00:47:00Z",
"file_size": null,
"sha256": null,
"updated_at": null,
"deleted": false,
"tags": ["oc", "male", "beastmen"]
}
]