diff --git a/backend/app/controllers/materials_controller.rb b/backend/app/controllers/materials_controller.rb index bf383ac..3274186 100644 --- a/backend/app/controllers/materials_controller.rb +++ b/backend/app/controllers/materials_controller.rb @@ -12,33 +12,47 @@ class MaterialsController < ApplicationController offset = (page - 1) * limit - tag_id = params[:tag_id].presence - parent_id = params[:parent_id].presence - unclassified = bool?(:unclassified) + filters = material_index_filters + q = Material.includes(:material_export_items, + thumbnail_attachment: :blob, + file_attachment: :blob, + tag: :tag_name) + q = q.where(file_suppressed_at: nil) if filters[:suppression] == 'active' + q = q.where.not(file_suppressed_at: nil) if filters[:suppression] == 'suppressed' + q = q.where(tag_id: nil) if filters[:tag_state] == 'untagged' + q = q.where.not(tag_id: nil) if filters[:tag_state] == 'tagged' + q = q.where('materials.created_at >= ?', filters[:created_from]) if filters[:created_from] + q = q.where('materials.created_at <= ?', filters[:created_to]) if filters[:created_to] + q = q.where('materials.updated_at >= ?', filters[:updated_from]) if filters[:updated_from] + q = q.where('materials.updated_at <= ?', filters[:updated_to]) if filters[:updated_to] - q = Material.includes(:tag, :created_by_user, :material_export_items).with_attached_file - if unclassified - q = q.where(tag_id: nil) - else - q = q.where(tag_id:) if tag_id - q = q.where(parent_id:) if parent_id - end + q = material_index_join_tag_name(q) if material_index_needs_tag_name?(filters) + q = material_index_join_file_blob(q) if material_index_needs_file_blob?(filters) + q = apply_material_query(q, filters[:q]) if filters[:q].present? + q = apply_material_media_kind(q, filters[:media_kind]) - count = q.count - materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset) + count = q.distinct.count(:id) + materials = + q + .order(Arel.sql(material_index_order_sql(filters))) + .limit(limit) + .offset(offset) + .to_a - render json: { materials: MaterialRepr.many(materials, host: request.base_url), count: count } + render json: { materials: MaterialRepr.list_many(materials, host: request.base_url), + count: } end def show material = Material .includes(:tag, :material_export_items) + .with_attached_thumbnail .with_attached_file .find_by(id: params[:id]) return head :not_found unless material - wiki_page_body = material.tag.tag_name.wiki_page&.current_revision&.body + wiki_page_body = material.tag&.tag_name&.wiki_page&.current_revision&.body render json: MaterialRepr.base(material, host: request.base_url).merge(wiki_page_body:) end @@ -83,6 +97,8 @@ class MaterialsController < ApplicationController end if material + MaterialThumbnailGenerator.generate!(material) + material.reload render json: MaterialRepr.base(material, host: request.base_url), status: :created else render_validation_error material @@ -134,6 +150,9 @@ class MaterialsController < ApplicationController raise end + MaterialThumbnailGenerator.generate!(material) + material.reload + render json: MaterialRepr.base(material, host: request.base_url) end @@ -196,6 +215,134 @@ class MaterialsController < ApplicationController private + def material_index_filters + tag_state = params[:tag_state].to_s.presence + tag_state = 'untagged' if bool?(:unclassified) + tag_state = 'all' unless ['all', 'tagged', 'untagged'].include?(tag_state) + + media_kind = params[:media_kind].to_s.presence + unless ['all', 'image', 'video', 'audio', 'file_other', 'url_only'].include?(media_kind) + media_kind = 'all' + end + + suppression = params[:suppression].to_s.presence + suppression = 'active' unless ['active', 'suppressed', 'all'].include?(suppression) + + sort = params[:sort].to_s.presence + unless ['created_at', 'updated_at', 'tag_name', 'media_kind', 'file_byte_size', + 'version_no', 'id'].include?(sort) + sort = 'created_at' + end + + direction = params[:direction].to_s.downcase + direction = 'desc' unless ['asc', 'desc'].include?(direction) + + { q: params[:q].to_s.strip.presence, + tag_state:, + media_kind:, + suppression:, + created_from: parse_time_param(:created_from), + created_to: parse_time_param(:created_to), + updated_from: parse_time_param(:updated_from), + updated_to: parse_time_param(:updated_to), + sort:, + direction: } + end + + def parse_time_param name + value = params[name].to_s.strip + return nil if value.blank? + + Time.zone.parse(value) + rescue ArgumentError + nil + end + + def material_index_needs_tag_name? filters + filters[:q].present? || filters[:sort] == 'tag_name' + end + + def material_index_needs_file_blob? filters + filters[:q].present? || + filters[:media_kind] != 'all' || + ['media_kind', 'file_byte_size'].include?(filters[:sort]) + end + + def material_index_join_tag_name q + q.left_joins(tag: :tag_name) + end + + def material_index_join_file_blob q + q.joins(<<~SQL.squish) + LEFT JOIN active_storage_attachments material_file_attachments + ON material_file_attachments.record_type = 'Material' + AND material_file_attachments.record_id = materials.id + AND material_file_attachments.name = 'file' + LEFT JOIN active_storage_blobs material_file_blobs + ON material_file_blobs.id = material_file_attachments.blob_id + SQL + end + + def apply_material_query q, term + like = "%#{ ActiveRecord::Base.sanitize_sql_like(term) }%" + q.where('tag_names.name LIKE :q OR materials.url LIKE :q OR ' \ + 'material_file_blobs.filename LIKE :q', + q: like) + end + + def apply_material_media_kind q, media_kind + case media_kind + when 'image' + q.where('material_file_blobs.content_type LIKE ?', 'image/%') + when 'video' + q.where('material_file_blobs.content_type LIKE ?', 'video/%') + when 'audio' + q.where('material_file_blobs.content_type LIKE ?', 'audio/%') + when 'file_other' + q.where('material_file_attachments.id IS NOT NULL') + .where.not('material_file_blobs.content_type LIKE ?', 'image/%') + .where.not('material_file_blobs.content_type LIKE ?', 'video/%') + .where.not('material_file_blobs.content_type LIKE ?', 'audio/%') + when 'url_only' + q.where('material_file_attachments.id IS NULL').where.not(url: [nil, '']) + else + q + end + end + + def material_index_order_sql filters + direction = filters[:direction] == 'asc' ? 'ASC' : 'DESC' + sort_sql = + case filters[:sort] + when 'tag_name' + 'tag_names.name' + when 'media_kind' + material_media_kind_sql + when 'file_byte_size' + 'material_file_blobs.byte_size' + when 'updated_at' + 'materials.updated_at' + when 'version_no' + 'materials.version_no' + when 'id' + 'materials.id' + else + 'materials.created_at' + end + + "#{ sort_sql } #{ direction }, materials.id #{ direction }" + end + + def material_media_kind_sql + "CASE " \ + "WHEN material_file_attachments.id IS NULL AND materials.url IS NOT NULL THEN 5 " \ + "WHEN material_file_blobs.content_type LIKE 'image/%' THEN 1 " \ + "WHEN material_file_blobs.content_type LIKE 'video/%' THEN 2 " \ + "WHEN material_file_blobs.content_type LIKE 'audio/%' THEN 3 " \ + "WHEN material_file_attachments.id IS NOT NULL THEN 4 " \ + "ELSE 6 END" + end + def upsert_export_paths! material raw = params[:export_paths] return if raw.blank? diff --git a/backend/app/models/material.rb b/backend/app/models/material.rb index 719e221..b480ed0 100644 --- a/backend/app/models/material.rb +++ b/backend/app/models/material.rb @@ -15,6 +15,7 @@ class Material < ApplicationRecord has_many :material_export_items, dependent: :destroy has_one_attached :file, dependent: :purge + has_one_attached :thumbnail, dependent: :purge validates :tag_id, presence: true, uniqueness: true @@ -28,6 +29,18 @@ class Material < ApplicationRecord file.blob.content_type end + def file_byte_size + return nil unless file&.attached? + + file.blob.byte_size + end + + def file_filename + return nil unless file&.attached? + + file.blob.filename.to_s + end + def file_suppressed? = file_suppressed_at.present? def snapshot_export_paths diff --git a/backend/app/representations/material_repr.rb b/backend/app/representations/material_repr.rb index 20e4834..de4ad05 100644 --- a/backend/app/representations/material_repr.rb +++ b/backend/app/representations/material_repr.rb @@ -17,6 +17,11 @@ module MaterialRepr Rails.application.routes.url_helpers.rails_storage_proxy_url( material.file, host:) end, + thumbnail: thumbnail_url(material, host:), + thumbnail_fallback_text: thumbnail_fallback_text(material), + thumbnail_fallback_kind: thumbnail_fallback_kind(material), + media_kind: media_kind(material), + file_byte_size: material.file_byte_size, export_paths: export_paths(material), export_items: export_items(material)) end @@ -25,6 +30,28 @@ module MaterialRepr materials.map { |m| base(m, host:) } end + def list material, host: + { id: material.id, + version_no: material.version_no, + url: material.url, + tag: compact_tag(material.tag), + thumbnail: thumbnail_url(material, host:), + thumbnail_fallback_text: thumbnail_fallback_text(material), + thumbnail_fallback_kind: thumbnail_fallback_kind(material), + media_kind: media_kind(material), + content_type: material.content_type, + file_byte_size: material.file_byte_size, + file_suppressed_at: material.file_suppressed_at, + created_at: material.created_at, + updated_at: material.updated_at, + export_paths: export_paths(material), + export_items: export_items(material) } + end + + def list_many materials, host: + materials.map { |m| list(m, host:) } + end + def export_paths material material.material_export_items.each_with_object({ }) do |item, hash| hash[item.profile] = item.enabled ? item.export_path : '' @@ -39,4 +66,41 @@ module MaterialRepr enabled: item.enabled } end end + + def thumbnail_url material, host: + return nil if material.file_suppressed? + return nil unless material.thumbnail.attached? + + Rails.application.routes.url_helpers.rails_storage_proxy_url( + material.thumbnail, host:) + end + + def thumbnail_fallback_text material + material.tag&.name || material.created_at&.strftime('%Y-%m-%d') + end + + def thumbnail_fallback_kind material + material.tag.present? ? 'tag_name' : 'created_at' + end + + def media_kind material + return 'suppressed' if material.file_suppressed? + return 'url_only' unless material.file.attached? + + content_type = material.file.blob.content_type.to_s + return 'image' if content_type.start_with?('image/') + return 'video' if content_type.start_with?('video/') + return 'audio' if content_type.start_with?('audio/') + + 'file_other' + end + + def compact_tag tag + return nil unless tag + + { id: tag.id, + name: tag.name, + category: tag.category, + deprecated_at: tag.deprecated_at } + end end diff --git a/backend/app/services/material_thumbnail_generator.rb b/backend/app/services/material_thumbnail_generator.rb new file mode 100644 index 0000000..0f809c5 --- /dev/null +++ b/backend/app/services/material_thumbnail_generator.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'mini_magick' +require 'open3' +require 'tempfile' + + +class MaterialThumbnailGenerator + SIZE = '180x180' + + class << self + def generate! material + new(material).generate! + rescue ActiveStorage::FileNotFoundError, ArgumentError, MiniMagick::Error => e + Rails.logger.warn("Material thumbnail generation skipped: #{ e.class }: #{ e.message }") + nil + end + end + + def initialize material + @material = material + end + + def generate! + @material.thumbnail.purge if @material.thumbnail.attached? + return unless @material.file.attached? + return unless image? || video? + + @material.file.blob.open do |file| + thumbnail = image? ? image_thumbnail(file.path) : video_thumbnail(file.path) + attach_thumbnail(thumbnail) if thumbnail + end + end + + private + + def image? = content_type.start_with?('image/') + + def video? = content_type.start_with?('video/') + + def content_type = @material.file.blob.content_type.to_s + + def image_thumbnail path + image = MiniMagick::Image.open(path) + image.resize(SIZE) + image.format('jpg') + image + end + + def video_thumbnail path + [1, 0].each do |seconds| + tempfile = Tempfile.new(['material-thumbnail', '.jpg']) + tempfile.close + ok = extract_video_frame(path, tempfile.path, seconds) + next unless ok && File.size?(tempfile.path) + + return image_thumbnail(tempfile.path) + ensure + tempfile&.unlink + end + + nil + end + + def extract_video_frame input_path, output_path, seconds + _stdout, stderr, status = + Open3.capture3('ffmpeg', + '-y', + '-ss', seconds.to_s, + '-i', input_path, + '-frames:v', '1', + '-f', 'image2', + output_path) + Rails.logger.warn("ffmpeg thumbnail failed: #{ stderr }") unless status.success? + status.success? + rescue Errno::ENOENT => e + Rails.logger.warn("ffmpeg unavailable for material thumbnail: #{ e.message }") + false + end + + def attach_thumbnail image + @material.thumbnail.attach(io: File.open(image.path), + filename: 'material-thumbnail.jpg', + content_type: 'image/jpeg') + end +end diff --git a/frontend/src/components/MaterialSidebar.tsx b/frontend/src/components/MaterialSidebar.tsx index 4122acb..39b066d 100644 --- a/frontend/src/components/MaterialSidebar.tsx +++ b/frontend/src/components/MaterialSidebar.tsx @@ -40,7 +40,7 @@ const setChildrenById = ( const materialPath = ( tagName: string, materialFilter: MaterialFilter, -): string => `/materials?tag=${ encodeURIComponent (tagName) }&material_filter=${ materialFilter }` +): string => `/materials?q=${ encodeURIComponent (tagName) }&material_filter=${ materialFilter }` const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({ diff --git a/frontend/src/lib/materials.ts b/frontend/src/lib/materials.ts index 900c9c4..5e2b570 100644 --- a/frontend/src/lib/materials.ts +++ b/frontend/src/lib/materials.ts @@ -8,19 +8,12 @@ import { import type { Material, + FetchMaterialsParams, MaterialFilter, MaterialSidebarTag, MaterialTagTree, } from '@/types' -export type FetchMaterialsParams = { - page?: number - limit?: number - tagId?: number | null - parentId?: number | null - unclassified?: boolean -} - export type FetchMaterialTreeParams = { parentId?: number | null materialFilter: MaterialFilter @@ -44,14 +37,22 @@ export const parseMaterialFilter = ( export const fetchMaterials = async ( - { page, limit, tagId, parentId, unclassified }: FetchMaterialsParams, + { q, tagState, mediaKind, suppression, createdFrom, createdTo, + updatedFrom, updatedTo, sort, direction, page, limit }: FetchMaterialsParams, ): Promise => await apiGet ('/materials', { params: { - ...(page != null && { page }), - ...(limit != null && { limit }), - ...(tagId != null && { tag_id: tagId }), - ...(parentId != null && { parent_id: parentId }), - ...(unclassified && { unclassified: '1' }), + ...(q && { q }), + tag_state: tagState, + media_kind: mediaKind, + suppression, + ...(createdFrom && { created_from: createdFrom }), + ...(createdTo && { created_to: createdTo }), + ...(updatedFrom && { updated_from: updatedFrom }), + ...(updatedTo && { updated_to: updatedTo }), + sort, + direction, + page, + limit, } }) diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 3e5f017..3649838 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -1,5 +1,6 @@ import type { FetchNicoTagsParams, + FetchMaterialsParams, FetchPostsParams, FetchTagsParams, MaterialFilter, @@ -32,13 +33,7 @@ export const tagsKeys = { export const materialsKeys = { root: ['materials'] as const, - index: (p: { - page?: number - limit?: number - tagId?: number | null - parentId?: number | null - unclassified?: boolean - }) => ['materials', 'index', p] as const, + index: (p: FetchMaterialsParams) => ['materials', 'index', p] as const, byTagName: (name: string, materialFilter: MaterialFilter) => ['materials', 'tag', name, materialFilter] as const, show: (id: string) => ['materials', id] as const, diff --git a/frontend/src/pages/materials/MaterialDetailPage.tsx b/frontend/src/pages/materials/MaterialDetailPage.tsx index a8acf66..523962a 100644 --- a/frontend/src/pages/materials/MaterialDetailPage.tsx +++ b/frontend/src/pages/materials/MaterialDetailPage.tsx @@ -47,12 +47,15 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => { queryFn: () => fetchMaterial (id ?? ''), enabled: id != null, }) + const materialTitle = material + ? material.tag?.name ?? `素材 #${ material.id }` + : '' useEffect (() => { if (!(material)) return - setTag (material.tag.name) + setTag (material.tag?.name ?? '') setURL (material.url ?? '') setExportPath (material.exportPaths.legacyDrive ?? '') if (material.file && material.contentType) @@ -124,7 +127,7 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => { {material && ( - {`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`} + {`${ materialTitle } 素材照会 | ${ SITE_TITLE }`} )} {isLoading ? 'Loading...' : isError ? ( @@ -138,10 +141,13 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => { ) : ( <> - + {material.tag + ? ( + ) + : materialTitle} {material.fileSuppressedAt && ( @@ -155,7 +161,7 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => { {(!material.fileSuppressedAt && material.file && material.contentType) && ( (/image\/.*/.test (material.contentType) && ( - {material.tag.name)) + {material.tag?.name)) || (/video\/.*/.test (material.contentType) && (