Compare commits
9 Commits
3356bddd60
...
bc4143ae12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc4143ae12 | ||
|
|
ca4729f7d1 | ||
|
|
3d2599a4ab | ||
|
|
072bbb849e | ||
|
|
56ed78faaf | ||
|
|
bd6246d29a | ||
|
|
5aeee4fe14 | ||
|
|
f11a5782e1 | ||
|
|
dca8ba4566 |
@@ -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
|
||||
|
||||
@@ -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"}'
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 || ""
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") %>"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" %>
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
77
db/migrate/20250725192431_migrate_fa_posts_to_aux.rb
Normal file
77
db/migrate/20250725192431_migrate_fa_posts_to_aux.rb
Normal 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
|
||||
5
db/migrate/20250726051451_create_index_on_fa_id.rb
Normal file
5
db/migrate/20250726051451_create_index_on_fa_id.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class CreateIndexOnFaId < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_index :domain_posts_fa_aux, :fa_id
|
||||
end
|
||||
end
|
||||
17
db/migrate/20250726051748_make_index_unique_on_fa_id.rb
Normal file
17
db/migrate/20250726051748_make_index_unique_on_fa_id.rb
Normal 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
|
||||
@@ -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'),
|
||||
|
||||
Submodule gems/has_aux_table updated: 4249329fa3...8f610b8fa7
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
18
test/fixtures/files/fuzzysearch/61429615.json
vendored
Normal file
18
test/fixtures/files/fuzzysearch/61429615.json
vendored
Normal 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"]
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user