diff --git a/apps/server/drizzle/0022_sleepy_turbo.sql b/apps/server/drizzle/0022_sleepy_turbo.sql new file mode 100644 index 000000000..e63864f04 --- /dev/null +++ b/apps/server/drizzle/0022_sleepy_turbo.sql @@ -0,0 +1,20 @@ +DO $$ +BEGIN + DELETE FROM "jobs" AS duplicate + USING "jobs" AS retained + WHERE duplicate."type" = 'generate_thumbnail' + AND duplicate."status" = 'pending' + AND retained."type" = duplicate."type" + AND retained."status" IN ('pending', 'in_progress') + AND retained."source_id" = duplicate."source_id" + AND retained."payload"->>'mediaId' = duplicate."payload"->>'mediaId' + AND retained."payload"->>'size' = duplicate."payload"->>'size' + AND ( + retained."status" = 'in_progress' + OR (retained."created_at", retained."id") < (duplicate."created_at", duplicate."id") + ); + + CREATE UNIQUE INDEX "uq_jobs_active_thumbnail" ON "jobs" USING btree ("source_id",("payload"->>'mediaId'),("payload"->>'size')) WHERE "jobs"."type" = 'generate_thumbnail' + AND "jobs"."status" IN ('pending', 'in_progress') + AND "jobs"."source_id" IS NOT NULL; +END $$; diff --git a/apps/server/drizzle/meta/0022_snapshot.json b/apps/server/drizzle/meta/0022_snapshot.json new file mode 100644 index 000000000..97a2984e0 --- /dev/null +++ b/apps/server/drizzle/meta/0022_snapshot.json @@ -0,0 +1,3341 @@ +{ + "id": "afa14b85-ff46-4c86-a56e-d20372ad79ce", + "prevId": "19ee548d-c82e-4887-a8a8-06f7f3f58cf5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.author_accounts": { + "name": "author_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "author_platform", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_url": { + "name": "profile_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_author_accounts_author_id": { + "name": "idx_author_accounts_author_id", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_author_accounts_platform_account_unique": { + "name": "idx_author_accounts_platform_account_unique", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "author_accounts_author_id_authors_id_fk": { + "name": "author_accounts_author_id_authors_id_fk", + "tableFrom": "author_accounts", + "tableTo": "authors", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.authors": { + "name": "authors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_authors_account_id": { + "name": "idx_authors_account_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_authors_name": { + "name": "idx_authors_name", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#808080'" + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "categories_parent_id_categories_id_fk": { + "name": "categories_parent_id_categories_id_fk", + "tableFrom": "categories", + "tableTo": "categories", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_name_unique": { + "name": "categories_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ccip_embeddings": { + "name": "ccip_embeddings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "region_id": { + "name": "region_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(768)", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding_version": { + "name": "embedding_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "media_modified_at": { + "name": "media_modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "extracted_at": { + "name": "extracted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_ccip_embeddings_region_id": { + "name": "idx_ccip_embeddings_region_id", + "columns": [ + { + "expression": "region_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ccip_embeddings_embedding_cosine": { + "name": "idx_ccip_embeddings_embedding_cosine", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + } + }, + "foreignKeys": { + "ccip_embeddings_region_id_media_regions_id_fk": { + "name": "ccip_embeddings_region_id_media_regions_id_fk", + "tableFrom": "ccip_embeddings", + "tableTo": "media_regions", + "columnsFrom": [ + "region_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_ccip_embeddings_region_model_version": { + "name": "uq_ccip_embeddings_region_model_version", + "nullsNotDistinct": false, + "columns": [ + "region_id", + "model", + "embedding_version" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_ips": { + "name": "character_ips", + "schema": "", + "columns": { + "character_id": { + "name": "character_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ip_id": { + "name": "ip_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + } + }, + "indexes": { + "idx_character_ips_ip_id_character_id": { + "name": "idx_character_ips_ip_id_character_id", + "columns": [ + { + "expression": "ip_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "character_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "character_ips_character_id_characters_id_fk": { + "name": "character_ips_character_id_characters_id_fk", + "tableFrom": "character_ips", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "character_ips_ip_id_ips_id_fk": { + "name": "character_ips_ip_id_ips_id_fk", + "tableFrom": "character_ips", + "tableTo": "ips", + "columnsFrom": [ + "ip_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "character_ips_character_id_ip_id_pk": { + "name": "character_ips_character_id_ip_id_pk", + "columns": [ + "character_id", + "ip_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.characters": { + "name": "characters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "aliases": { + "name": "aliases", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "characters_name_unique": { + "name": "characters_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "collections_user_id_users_id_fk": { + "name": "collections_user_id_users_id_fk", + "tableFrom": "collections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ips": { + "name": "ips", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ips_name_unique": { + "name": "ips_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_jobs_pending_import_request": { + "name": "idx_jobs_pending_import_request", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"jobs\".\"status\" = 'pending' AND \"jobs\".\"type\" = 'import_request'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_jobs_pending_created": { + "name": "idx_jobs_pending_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"jobs\".\"status\" = 'pending' AND \"jobs\".\"type\" <> 'import_request'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_jobs_pending_type_created": { + "name": "idx_jobs_pending_type_created", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"jobs\".\"status\" = 'pending' AND \"jobs\".\"type\" <> 'import_request'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_jobs_pending_lancedb_source": { + "name": "idx_jobs_pending_lancedb_source", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"jobs\".\"status\" = 'pending'\n\t\t\t\t\tAND \"jobs\".\"type\" IN ('sync_lancedb', 'sync_lancedb_full', 'sync_lancedb_delta')\n\t\t\t\t\tAND \"jobs\".\"source_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_jobs_active_lancedb_source": { + "name": "idx_jobs_active_lancedb_source", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"jobs\".\"status\" = 'in_progress'\n\t\t\t\t\tAND \"jobs\".\"type\" IN ('sync_lancedb', 'sync_lancedb_full', 'sync_lancedb_delta')\n\t\t\t\t\tAND \"jobs\".\"source_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_jobs_active_thumbnail": { + "name": "uq_jobs_active_thumbnail", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "(\"payload\"->>'mediaId')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "(\"payload\"->>'size')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"jobs\".\"type\" = 'generate_thumbnail'\n\t\t\t\t\tAND \"jobs\".\"status\" IN ('pending', 'in_progress')\n\t\t\t\t\tAND \"jobs\".\"source_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "jobs_source_id_media_sources_id_fk": { + "name": "jobs_source_id_media_sources_id_fk", + "tableFrom": "jobs", + "tableTo": "media_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "jobs_parent_id_jobs_id_fk": { + "name": "jobs_parent_id_jobs_id_fk", + "tableFrom": "jobs", + "tableTo": "jobs", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lancedb_sync_dirty": { + "name": "lancedb_sync_dirty", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'upsert'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_lancedb_sync_dirty_source_updated": { + "name": "idx_lancedb_sync_dirty_source_updated", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lancedb_sync_dirty_source_id_media_sources_id_fk": { + "name": "lancedb_sync_dirty_source_id_media_sources_id_fk", + "tableFrom": "lancedb_sync_dirty", + "tableTo": "media_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "lancedb_sync_dirty_source_media_unique": { + "name": "lancedb_sync_dirty_source_media_unique", + "nullsNotDistinct": false, + "columns": [ + "source_id", + "media_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media_authors": { + "name": "media_authors", + "schema": "", + "columns": { + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_media_authors_author_id_media_id": { + "name": "idx_media_authors_author_id_media_id", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_authors_media_id_media_id_fk": { + "name": "media_authors_media_id_media_id_fk", + "tableFrom": "media_authors", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_authors_author_id_authors_id_fk": { + "name": "media_authors_author_id_authors_id_fk", + "tableFrom": "media_authors", + "tableTo": "authors", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_authors_media_id_author_id_pk": { + "name": "media_authors_media_id_author_id_pk", + "columns": [ + "media_id", + "author_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media_categories": { + "name": "media_categories", + "schema": "", + "columns": { + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_media_categories_category_id_media_id": { + "name": "idx_media_categories_category_id_media_id", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_categories_media_id_media_id_fk": { + "name": "media_categories_media_id_media_id_fk", + "tableFrom": "media_categories", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_categories_category_id_categories_id_fk": { + "name": "media_categories_category_id_categories_id_fk", + "tableFrom": "media_categories", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_categories_media_id_category_id_pk": { + "name": "media_categories_media_id_category_id_pk", + "columns": [ + "media_id", + "category_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media_characters": { + "name": "media_characters", + "schema": "", + "columns": { + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + } + }, + "indexes": { + "idx_media_characters_character_id_media_id": { + "name": "idx_media_characters_character_id_media_id", + "columns": [ + { + "expression": "character_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_characters_media_id_media_id_fk": { + "name": "media_characters_media_id_media_id_fk", + "tableFrom": "media_characters", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_characters_character_id_characters_id_fk": { + "name": "media_characters_character_id_characters_id_fk", + "tableFrom": "media_characters", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_characters_media_id_character_id_pk": { + "name": "media_characters_media_id_character_id_pk", + "columns": [ + "media_id", + "character_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media_collections": { + "name": "media_collections", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_media_collections_media_id": { + "name": "idx_media_collections_media_id", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_collections_collection_id_collections_id_fk": { + "name": "media_collections_collection_id_collections_id_fk", + "tableFrom": "media_collections", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_collections_media_id_media_id_fk": { + "name": "media_collections_media_id_media_id_fk", + "tableFrom": "media_collections", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_collections_collection_id_media_id_pk": { + "name": "media_collections_collection_id_media_id_pk", + "columns": [ + "collection_id", + "media_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media_details": { + "name": "media_details", + "schema": "", + "columns": { + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "favorite": { + "name": "favorite", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "view_count": { + "name": "view_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "'1970-01-01 00:00:00'" + } + }, + "indexes": { + "idx_media_details_rating": { + "name": "idx_media_details_rating", + "columns": [ + { + "expression": "rating", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_media_details_favorite": { + "name": "idx_media_details_favorite", + "columns": [ + { + "expression": "favorite", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_media_details_view_count": { + "name": "idx_media_details_view_count", + "columns": [ + { + "expression": "view_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_details_media_id_media_id_fk": { + "name": "media_details_media_id_media_id_fk", + "tableFrom": "media_details", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media_generation_info": { + "name": "media_generation_info", + "schema": "", + "columns": { + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "negative_prompt": { + "name": "negative_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow": { + "name": "workflow", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "loras": { + "name": "loras", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "vae": { + "name": "vae", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hypernetworks": { + "name": "hypernetworks", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "embeddings": { + "name": "embeddings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "seed": { + "name": "seed", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": -1 + }, + "cfg_scale": { + "name": "cfg_scale", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "steps": { + "name": "steps", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + } + }, + "indexes": { + "idx_media_generation_info_metadata": { + "name": "idx_media_generation_info_metadata", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_media_generation_info_ai_generated": { + "name": "idx_media_generation_info_ai_generated", + "columns": [ + { + "expression": "ai_generated", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_media_generation_info_model_name": { + "name": "idx_media_generation_info_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_generation_info_media_id_media_id_fk": { + "name": "media_generation_info_media_id_media_id_fk", + "tableFrom": "media_generation_info", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media_ips": { + "name": "media_ips", + "schema": "", + "columns": { + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ip_id": { + "name": "ip_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + } + }, + "indexes": { + "idx_media_ips_ip_id_media_id": { + "name": "idx_media_ips_ip_id_media_id", + "columns": [ + { + "expression": "ip_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_ips_media_id_media_id_fk": { + "name": "media_ips_media_id_media_id_fk", + "tableFrom": "media_ips", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_ips_ip_id_ips_id_fk": { + "name": "media_ips_ip_id_ips_id_fk", + "tableFrom": "media_ips", + "tableTo": "ips", + "columnsFrom": [ + "ip_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_ips_media_id_ip_id_pk": { + "name": "media_ips_media_id_ip_id_pk", + "columns": [ + "media_id", + "ip_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media_projects": { + "name": "media_projects", + "schema": "", + "columns": { + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_media_projects_project_id_media_id": { + "name": "idx_media_projects_project_id_media_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_projects_media_id_media_id_fk": { + "name": "media_projects_media_id_media_id_fk", + "tableFrom": "media_projects", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_projects_project_id_projects_id_fk": { + "name": "media_projects_project_id_projects_id_fk", + "tableFrom": "media_projects", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_projects_media_id_project_id_pk": { + "name": "media_projects_media_id_project_id_pk", + "columns": [ + "media_id", + "project_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media_regions": { + "name": "media_regions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "media_region_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "x": { + "name": "x", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "y": { + "name": "y", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "source_modified_at": { + "name": "source_modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "detector": { + "name": "detector", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detector_version": { + "name": "detector_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_media_regions_media_id": { + "name": "idx_media_regions_media_id", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_media_regions_full_media_id": { + "name": "uq_media_regions_full_media_id", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"media_regions\".\"kind\" = 'full'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_regions_media_id_media_id_fk": { + "name": "media_regions_media_id_media_id_fk", + "tableFrom": "media_regions", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "media_regions_bbox_by_kind": { + "name": "media_regions_bbox_by_kind", + "value": "(\n\t\t\t\t(\"media_regions\".\"kind\" = 'full' AND \"media_regions\".\"x\" IS NULL AND \"media_regions\".\"y\" IS NULL AND \"media_regions\".\"width\" IS NULL AND \"media_regions\".\"height\" IS NULL)\n\t\t\t\tOR\n\t\t\t\t(\"media_regions\".\"kind\" <> 'full' AND \"media_regions\".\"x\" IS NOT NULL AND \"media_regions\".\"y\" IS NOT NULL AND \"media_regions\".\"width\" IS NOT NULL AND \"media_regions\".\"height\" IS NOT NULL\n\t\t\t\t\tAND \"media_regions\".\"x\" >= 0 AND \"media_regions\".\"y\" >= 0 AND \"media_regions\".\"width\" > 0 AND \"media_regions\".\"height\" > 0\n\t\t\t\t\tAND \"media_regions\".\"x\" + \"media_regions\".\"width\" <= 1 AND \"media_regions\".\"y\" + \"media_regions\".\"height\" <= 1)\n\t\t\t)" + }, + "media_regions_score_range": { + "name": "media_regions_score_range", + "value": "\"media_regions\".\"score\" IS NULL OR (\"media_regions\".\"score\" >= 0 AND \"media_regions\".\"score\" <= 1)" + } + }, + "isRLSEnabled": false + }, + "public.media_relations": { + "name": "media_relations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "parent_media_id": { + "name": "parent_media_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "child_media_id": { + "name": "child_media_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "relation_type": { + "name": "relation_type", + "type": "media_relation_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "order_index": { + "name": "order_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_media_relations_child": { + "name": "idx_media_relations_child", + "columns": [ + { + "expression": "child_media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_media_relations_type": { + "name": "idx_media_relations_type", + "columns": [ + { + "expression": "relation_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_relations_parent_media_id_media_id_fk": { + "name": "media_relations_parent_media_id_media_id_fk", + "tableFrom": "media_relations", + "tableTo": "media", + "columnsFrom": [ + "parent_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_relations_child_media_id_media_id_fk": { + "name": "media_relations_child_media_id_media_id_fk", + "tableFrom": "media_relations", + "tableTo": "media", + "columnsFrom": [ + "child_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "parent_child_type_unique": { + "name": "parent_child_type_unique", + "nullsNotDistinct": false, + "columns": [ + "parent_media_id", + "child_media_id", + "relation_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media_sources": { + "name": "media_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "media_source_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "connection_info": { + "name": "connection_info", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media_sync": { + "name": "media_sync", + "schema": "", + "columns": { + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "sync_status": { + "name": "sync_status", + "type": "media_sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'synced'" + }, + "backup_urls": { + "name": "backup_urls", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_attempts": { + "name": "sync_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "media_sync_media_id_media_id_fk": { + "name": "media_sync_media_id_media_id_fk", + "tableFrom": "media_sync", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media_tags": { + "name": "media_tags", + "schema": "", + "columns": { + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_type": { + "name": "tag_type", + "type": "tag_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'positive'" + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + } + }, + "indexes": { + "idx_media_tags_tag_id_tag_type_media_id": { + "name": "idx_media_tags_tag_id_tag_type_media_id", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_tags_media_id_media_id_fk": { + "name": "media_tags_media_id_media_id_fk", + "tableFrom": "media_tags", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_tags_tag_id_tags_id_fk": { + "name": "media_tags_tag_id_tags_id_fk", + "tableFrom": "media_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_tags_media_id_tag_id_tag_type_pk": { + "name": "media_tags_media_id_tag_id_tag_type_pk", + "columns": [ + "media_id", + "tag_id", + "tag_type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media_technical_info": { + "name": "media_technical_info", + "schema": "", + "columns": { + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "color_profile": { + "name": "color_profile", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "exif_data": { + "name": "exif_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "hash_md5": { + "name": "hash_md5", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "hash_perceptual": { + "name": "hash_perceptual", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "frame_rate": { + "name": "frame_rate", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "bitrate_kbps": { + "name": "bitrate_kbps", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "video_codec": { + "name": "video_codec", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_codec": { + "name": "audio_codec", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_media_technical_info_hash_md5": { + "name": "idx_media_technical_info_hash_md5", + "columns": [ + { + "expression": "hash_md5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_technical_info_media_id_media_id_fk": { + "name": "media_technical_info_media_id_media_id_fk", + "tableFrom": "media_technical_info", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media_urls": { + "name": "media_urls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_media_urls_media_id": { + "name": "idx_media_urls_media_id", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_media_urls_url": { + "name": "idx_media_urls_url", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_media_urls_media_id_url_unique": { + "name": "idx_media_urls_media_id_url_unique", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_urls_media_id_media_id_fk": { + "name": "media_urls_media_id_media_id_fk", + "tableFrom": "media_urls", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "media_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "indexed_at": { + "name": "indexed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "media_organization_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + } + }, + "indexes": { + "idx_media_source_id": { + "name": "idx_media_source_id", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_media_file_size": { + "name": "idx_media_file_size", + "columns": [ + { + "expression": "file_size", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_media_file_name": { + "name": "idx_media_file_name", + "columns": [ + { + "expression": "file_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_media_created_at": { + "name": "idx_media_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_media_description": { + "name": "idx_media_description", + "columns": [ + { + "expression": "description", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_source_id_media_sources_id_fk": { + "name": "media_source_id_media_sources_id_fk", + "tableFrom": "media", + "tableTo": "media_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "source_id_file_path_unique": { + "name": "source_id_file_path_unique", + "nullsNotDistinct": false, + "columns": [ + "source_id", + "file_path" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.presets": { + "name": "presets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "sort": { + "name": "sort", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "presets_name_unique": { + "name": "presets_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_projects_name": { + "name": "idx_projects_name", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_name_unique": { + "name": "projects_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.similar_media": { + "name": "similar_media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "media1_id": { + "name": "media1_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "media2_id": { + "name": "media2_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "similarity_score": { + "name": "similarity_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'perceptual'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_similar_media_score": { + "name": "idx_similar_media_score", + "columns": [ + { + "expression": "similarity_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "similar_media_media1_id_media_id_fk": { + "name": "similar_media_media1_id_media_id_fk", + "tableFrom": "similar_media", + "tableTo": "media", + "columnsFrom": [ + "media1_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "similar_media_media2_id_media_id_fk": { + "name": "similar_media_media2_id_media_id_fk", + "tableFrom": "similar_media", + "tableTo": "media", + "columnsFrom": [ + "media2_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "media1Id_media2Id_algorithm_unique": { + "name": "media1Id_media2Id_algorithm_unique", + "nullsNotDistinct": false, + "columns": [ + "media1_id", + "media2_id", + "algorithm" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attribute": { + "name": "attribute", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_tags_author_id": { + "name": "idx_tags_author_id", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tags_author_id_authors_id_fk": { + "name": "tags_author_id_authors_id_fk", + "tableFrom": "tags", + "tableTo": "authors", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.view_history": { + "name": "view_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "viewed_at": { + "name": "viewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + } + }, + "indexes": {}, + "foreignKeys": { + "view_history_media_id_media_id_fk": { + "name": "view_history_media_id_media_id_fk", + "tableFrom": "view_history", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.author_platform": { + "name": "author_platform", + "schema": "public", + "values": [ + "twitter", + "pixiv-fanbox", + "danbooru" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "pending", + "in_progress", + "completed", + "failed" + ] + }, + "public.media_organization_status": { + "name": "media_organization_status", + "schema": "public", + "values": [ + "active", + "archived", + "deleted" + ] + }, + "public.media_region_kind": { + "name": "media_region_kind", + "schema": "public", + "values": [ + "full", + "person", + "manual" + ] + }, + "public.media_relation_type": { + "name": "media_relation_type", + "schema": "public", + "values": [ + "variant", + "version", + "page", + "derivative", + "edit", + "source" + ] + }, + "public.media_source_type": { + "name": "media_source_type", + "schema": "public", + "values": [ + "local", + "sftp", + "s3" + ] + }, + "public.media_sync_status": { + "name": "media_sync_status", + "schema": "public", + "values": [ + "synced", + "pending", + "failed" + ] + }, + "public.media_type": { + "name": "media_type", + "schema": "public", + "values": [ + "image", + "video", + "audio" + ] + }, + "public.tag_type": { + "name": "tag_type", + "schema": "public", + "values": [ + "positive", + "negative" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 0bb5c78b2..d7eeb7ea9 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1785608829741, "tag": "0021_clear_lancedb_delta_jobs", "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1785661239287, + "tag": "0022_sleepy_turbo", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/apps/server/nitro.config.ts b/apps/server/nitro.config.ts index e32fe49c4..3f77da017 100644 --- a/apps/server/nitro.config.ts +++ b/apps/server/nitro.config.ts @@ -28,21 +28,38 @@ export default defineNitroConfig({ fs.existsSync(pgliteLocalPath) ? pgliteLocalPath : pgliteRootPath, ); const pgliteDistPath = path.join(pglitePkgPath, "dist"); + const pgvectorLocalPath = path.resolve( + __dirname, + "node_modules/@electric-sql/pglite-pgvector/package.json", + ); + const pgvectorRootPath = path.resolve( + __dirname, + "../../node_modules/@electric-sql/pglite-pgvector/package.json", + ); + const pgvectorPkgPath = path.dirname( + fs.existsSync(pgvectorLocalPath) ? pgvectorLocalPath : pgvectorRootPath, + ); - const assetsToCopy = ["pglite.data", "pglite.wasm"]; + const assetsToCopy = [ + { name: "pglite.data", source: path.join(pgliteDistPath, "pglite.data") }, + { name: "pglite.wasm", source: path.join(pgliteDistPath, "pglite.wasm") }, + { + name: "vector.tar.gz", + source: path.join(pgvectorPkgPath, "dist/vector.tar.gz"), + }, + ]; for (const asset of assetsToCopy) { - const source = path.join(pgliteDistPath, asset); - const destination = path.join(libsDir, asset); + const destination = path.join(libsDir, asset.name); - if (fs.existsSync(source)) { + if (fs.existsSync(asset.source)) { if (!fs.existsSync(libsDir)) { fs.mkdirSync(libsDir, { recursive: true }); } - fs.copyFileSync(source, destination); - console.log(`[Nitro] Successfully copied ${asset} to ${destination}`); + fs.copyFileSync(asset.source, destination); + console.log(`[Nitro] Successfully copied ${asset.name} to ${destination}`); } else { - console.warn(`[Nitro] Warning: ${asset} not found at ${source}`); + console.warn(`[Nitro] Warning: ${asset.name} not found at ${asset.source}`); } } diff --git a/apps/server/public/openapi.json b/apps/server/public/openapi.json index 055685a12..ec1a9a928 100644 --- a/apps/server/public/openapi.json +++ b/apps/server/public/openapi.json @@ -1099,6 +1099,16 @@ }, "timestamp": { "type": "string" + }, + "size": { + "anyOf": [ + { + "const": 256 + }, + { + "const": 512 + } + ] } }, "required": [ @@ -4597,6 +4607,21 @@ "sourceId": { "type": "string", "format": "uuid" + }, + "size": { + "anyOf": [ + { + "const": 256 + }, + { + "const": 512 + } + ], + "default": 512 + }, + "missingOnly": { + "type": "boolean", + "default": false } }, "required": [ @@ -4612,11 +4637,28 @@ "content": { "application/json": { "schema": { - "anyOf": [ - {}, - { - "not": {} + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "jobId": { + "type": "string", + "format": "uuid" + }, + "message": { + "type": "string" } + }, + "required": [ + "success", + "count", + "message" ] } } diff --git a/apps/server/scripts/isolated-runtime.ts b/apps/server/scripts/isolated-runtime.ts index d023d6a23..9775a0c36 100644 --- a/apps/server/scripts/isolated-runtime.ts +++ b/apps/server/scripts/isolated-runtime.ts @@ -1,4 +1,5 @@ -import { mkdir, rm, stat, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { copyFile, mkdir, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { defaultAppConfig } from "@solid-imager/core/domain/config/config-schema"; import { mediaGenerationInfo, medias, mediaSources } from "@solid-imager/db/schema"; @@ -16,6 +17,7 @@ import { } from "../src/tests/e2e/support/fixture"; const appRoot = path.resolve(import.meta.dir, ".."); +const E2E_PAGINATED_MEDIA_COUNT = 600; export type IsolatedRuntime = { routeTreePath: string; @@ -42,8 +44,10 @@ function createImageSvg(accentColor: string, backgroundColor: string): Buffer { async function seedMediaFixtures(runtimeDir: string): Promise { const mediaDir = path.join(runtimeDir, "media"); const thumbnailDir = path.join(runtimeDir, "thumbnails", E2E_SOURCE_ID); + const smallThumbnailDir = path.join(thumbnailDir, "256"); await mkdir(mediaDir, { recursive: true }); await mkdir(thumbnailDir, { recursive: true }); + await mkdir(smallThumbnailDir, { recursive: true }); const primaryPath = path.join(mediaDir, E2E_PRIMARY_FILE_NAME); const similarPath = path.join(mediaDir, E2E_SIMILAR_FILE_NAME); @@ -64,6 +68,39 @@ async function seedMediaFixtures(runtimeDir: string): Promise { ]); const [primaryStats, similarStats] = await Promise.all([stat(primaryPath), stat(similarPath)]); + const paginatedMedia = Array.from({ length: E2E_PAGINATED_MEDIA_COUNT }, (_, index) => ({ + id: randomUUID(), + fileName: `e2e-scroll-${String(index + 1).padStart(4, "0")}.png`, + })); + const primaryThumbnailPath = path.join( + thumbnailDir, + `${E2E_PRIMARY_MEDIA_ID}.webp`, + ); + const similarThumbnailPath = path.join( + thumbnailDir, + `${E2E_SIMILAR_MEDIA_ID}.webp`, + ); + await Promise.all( + [ + copyFile( + primaryThumbnailPath, + path.join(smallThumbnailDir, `${E2E_PRIMARY_MEDIA_ID}.webp`), + ), + copyFile( + similarThumbnailPath, + path.join(smallThumbnailDir, `${E2E_SIMILAR_MEDIA_ID}.webp`), + ), + ...paginatedMedia.flatMap(({ id, fileName }) => [ + copyFile(primaryPath, path.join(mediaDir, fileName)), + copyFile(primaryThumbnailPath, path.join(thumbnailDir, `${id}.webp`)), + copyFile( + primaryThumbnailPath, + path.join(smallThumbnailDir, `${id}.webp`), + ), + ]), + ], + ); + const pgliteDir = path.join(runtimeDir, "pglite"); const client = createPglite(pgliteDir); const db = drizzle(client); @@ -111,6 +148,24 @@ async function seedMediaFixtures(runtimeDir: string): Promise { indexedAt: seededAt, status: "active", }, + ...paginatedMedia.map(({ id, fileName }, index) => { + const createdAt = new Date(seededAt.getTime() - (index + 2) * 1000); + return { + id, + mediaSourceId: E2E_SOURCE_ID, + filePath: fileName, + fileName, + mediaType: "image" as const, + width: 256, + height: 256, + fileSize: primaryStats.size, + description: "Paginated browser scroll fixture media", + createdAt, + modifiedAt: createdAt, + indexedAt: seededAt, + status: "active" as const, + }; + }), ]); await db.insert(mediaGenerationInfo).values([ { @@ -121,6 +176,10 @@ async function seedMediaFixtures(runtimeDir: string): Promise { mediaId: E2E_SIMILAR_MEDIA_ID, metadata: { fixture: "e2e-similar" }, }, + ...paginatedMedia.map(({ id }) => ({ + mediaId: id, + metadata: { fixture: "e2e-scroll" }, + })), ]); } finally { await client.close(); diff --git a/apps/server/src/application/services/job-dispatch-service.ts b/apps/server/src/application/services/job-dispatch-service.ts index da7d4c1cd..7a5be13a1 100644 --- a/apps/server/src/application/services/job-dispatch-service.ts +++ b/apps/server/src/application/services/job-dispatch-service.ts @@ -1,16 +1,35 @@ import type { DeferredActions } from "@solid-imager/application/ports/media-service"; +import type { Job } from "@solid-imager/core/domain/repositories/job-repository"; import { services } from "~/application/registry"; -import type { Job as DbJob } from "~/infrastructure/db/schema"; import { RealtimeEventBus } from "~/infrastructure/events/realtime-event-bus"; import { processAutoTaggingJob, processBulkTaggingDispatchJob, } from "~/infrastructure/jobs/tagging-jobs"; -import { deleteThumbnail } from "~/infrastructure/jobs/thumbnails"; import { logger } from "~/infrastructure/logger"; +export type ThumbnailJobHandlers = { + deleteThumbnail: (mediaSourceId: string, mediaId: string) => Promise; + processThumbnailGenerationJob: (job: Job) => Promise; +}; + +let thumbnailJobHandlers: ThumbnailJobHandlers | undefined; + +export function configureThumbnailJobHandlers( + handlers: ThumbnailJobHandlers, +): void { + thumbnailJobHandlers = handlers; +} + +function getThumbnailJobHandlers(): ThumbnailJobHandlers { + if (!thumbnailJobHandlers) { + throw new Error("Thumbnail job handlers have not been configured."); + } + return thumbnailJobHandlers; +} + // Helper for unified job processing (Called by JobWorker) -export async function processJob(job: DbJob) { +export async function processJob(job: Job) { const mediaSourceId = job.mediaSourceId; if ( !mediaSourceId && @@ -44,6 +63,8 @@ export async function processJob(job: DbJob) { "~/infrastructure/jobs/ccip-jobs" ); await processBatchCcipDispatchJob(job); + } else if (job.type === "generate_thumbnail") { + await getThumbnailJobHandlers().processThumbnailGenerationJob(job); } else if (job.type === "sync_lancedb" || job.type === "sync_lancedb_full") { if (!mediaSourceId) { throw new Error(`Job ${job.id} missing mediaSourceId`); @@ -157,6 +178,7 @@ export async function executeDeferredActions(actions: DeferredActions) { } } if (actions.thumbnailsToDelete && actions.thumbnailsToDelete.length > 0) { + const { deleteThumbnail } = getThumbnailJobHandlers(); for (const thumb of actions.thumbnailsToDelete) { try { await deleteThumbnail(thumb.mediaSourceId, thumb.mediaId); diff --git a/apps/server/src/application/services/thumbnail-service.ts b/apps/server/src/application/services/thumbnail-service.ts index 3f84e8392..02960db70 100644 --- a/apps/server/src/application/services/thumbnail-service.ts +++ b/apps/server/src/application/services/thumbnail-service.ts @@ -1,3 +1,4 @@ +import type { ThumbnailSize } from "@solid-imager/core/domain/thumbnails/schemas"; import { services } from "~/application/registry"; import { generateThumbnailsForSource, @@ -10,7 +11,7 @@ export const ThumbnailService = { getMediaThumbnailUrl( mediaSourceId: string, mediaId: string, - size?: number, + size?: ThumbnailSize, ): string { let url = `/api/sources/${mediaSourceId}/thumbnail/${mediaId}`; if (size) { @@ -19,9 +20,20 @@ export const ThumbnailService = { return url; }, - async startThumbnailGeneration(mediaSourceId: string) { - const count = await generateThumbnailsForSource(mediaSourceId); - return { success: true, count }; + async startThumbnailGeneration( + mediaSourceId: string, + options: { size: ThumbnailSize; missingOnly: boolean }, + ) { + const result = await generateThumbnailsForSource(mediaSourceId, options); + return { + success: true, + count: result.count, + jobId: result.jobId, + message: + result.count > 0 + ? `Queued ${result.count} thumbnail job(s)` + : "No thumbnails need generation", + }; }, async clearThumbnailCache(mediaSourceId: string) { diff --git a/apps/server/src/components/imports/pending-downloads-indicator.tsx b/apps/server/src/components/imports/pending-downloads-indicator.tsx index de84a1ef5..8fa28a2f3 100644 --- a/apps/server/src/components/imports/pending-downloads-indicator.tsx +++ b/apps/server/src/components/imports/pending-downloads-indicator.tsx @@ -1,11 +1,54 @@ import { subscribeToEventStream } from "@solid-imager/ui/event-stream"; import { + type ImportEventConnectedHandler, + type ImportEventHandler, type PendingDownloadsIndicatorProps, PendingDownloadsIndicator as SharedPendingDownloadsIndicator, } from "@solid-imager/ui/pending-downloads-indicator"; import { orpc } from "~/infrastructure/api-clients/orpc-client"; import { fetchMediaSources } from "~/infrastructure/api-clients/sources-api"; +type ImportSubscriber = { + handler: ImportEventHandler; + onConnected?: ImportEventConnectedHandler; +}; + +const importSubscribers = new Set(); +let cleanupImportStream: (() => void) | undefined; + +function subscribeSharedImportEvents( + handler: ImportEventHandler, + onConnected?: ImportEventConnectedHandler, +): () => void { + const subscriber = { handler, onConnected }; + importSubscribers.add(subscriber); + + if (!cleanupImportStream) { + cleanupImportStream = subscribeToEventStream( + (signal) => orpc.imports.events(undefined, { signal }), + (event) => { + for (const current of importSubscribers) { + void current.handler(event); + } + }, + undefined, + async () => { + for (const current of importSubscribers) { + await current.onConnected?.(); + } + }, + ); + } + + return () => { + importSubscribers.delete(subscriber); + if (importSubscribers.size === 0) { + cleanupImportStream?.(); + cleanupImportStream = undefined; + } + }; +} + export function PendingDownloadsIndicator( displayProps: { compact?: boolean; variant?: "default" | "v2" } = {}, ) { @@ -37,12 +80,7 @@ export function PendingDownloadsIndicator( return { success: result.success }; }, subscribeImportEvents: (handler, onConnected) => { - return subscribeToEventStream( - (signal) => orpc.imports.events(undefined, { signal }), - handler, - undefined, - onConnected, - ); + return subscribeSharedImportEvents(handler, onConnected); }, }; diff --git a/apps/server/src/components/media/media-grid-item.tsx b/apps/server/src/components/media/media-grid-item.tsx index acfcf0cb9..e24a260d6 100644 --- a/apps/server/src/components/media/media-grid-item.tsx +++ b/apps/server/src/components/media/media-grid-item.tsx @@ -1,5 +1,6 @@ import type { Media } from "@solid-imager/core/domain/media/schemas"; import { + type MediaGridImageLoadPolicy, type MediaGridLinkProps, MediaGridItem as SharedMediaGridItem, } from "@solid-imager/ui/media-grid-item"; @@ -11,6 +12,7 @@ type MediaGridItemProps = { linkPrefix?: string; routeVersion?: "default" | "v2"; media: Media; + imageLoadPolicy?: MediaGridImageLoadPolicy; onContextMenu?: (event: MouseEvent) => void; priority?: boolean; sourceRootPath?: string; @@ -84,6 +86,7 @@ export function MediaGridItem(props: MediaGridItemProps) { variant={props.routeVersion === "v2" ? "v2" : "default"} isBulkSelectMode={props.isBulkSelectMode} isSelected={props.isSelected} + imageLoadPolicy={props.imageLoadPolicy} linkComponent={(linkProps) => ( + + + +
+
+ +
+
+
+
+ + + +
+
+
+ + ); +} function Gallery() { const [dark, setDark] = createSignal(false); @@ -327,4 +522,7 @@ const root = document.querySelector("#root"); if (!(root instanceof HTMLElement)) { throw new Error("Gallery root element was not found"); } -render(() => , root); +const showVirtualGrid = new URLSearchParams(window.location.search).has( + "virtual-grid", +); +render(() => (showVirtualGrid ? : ), root); diff --git a/apps/server/src/tests/e2e/ui-gallery/vite.config.ts b/apps/server/src/tests/e2e/ui-gallery/vite.config.ts index 53a82caf8..1fa7c3412 100644 --- a/apps/server/src/tests/e2e/ui-gallery/vite.config.ts +++ b/apps/server/src/tests/e2e/ui-gallery/vite.config.ts @@ -1,12 +1,83 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import tailwindcss from "@tailwindcss/vite"; -import { defineConfig } from "vite"; +import sharp from "sharp"; +import { defineConfig, type Plugin } from "vite"; import solidPlugin from "vite-plugin-solid"; const galleryRoot = path.dirname(fileURLToPath(import.meta.url)); const workspaceRoot = path.resolve(galleryRoot, "../../../../../.."); +function createPhotoLikePixels(width: number, height: number): Buffer { + const pixels = Buffer.alloc(width * height * 3); + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const index = (y * width + x) * 3; + const noise = ((x * 17 + y * 31 + ((x * y) % 97)) % 37) - 18; + pixels[index] = Math.max( + 0, + Math.min(255, 40 + (x * 150) / width + noise), + ); + pixels[index + 1] = Math.max( + 0, + Math.min(255, 80 + (y * 120) / height + noise), + ); + pixels[index + 2] = Math.max( + 0, + Math.min(255, 190 + (Math.sin(x / 11) + Math.cos(y / 9)) * 25 + noise), + ); + } + } + return pixels; +} + +const thumbnailBuffers = new Map( + ([256, 512] as const).map((width) => { + const height = Math.round(width * 0.75); + const pixels = createPhotoLikePixels(width, height); + return [ + width, + sharp(pixels, { raw: { channels: 3, height, width } }) + .webp({ effort: 4, quality: 82 }) + .toBuffer(), + ] as const; + }), +); + +function virtualThumbnailPlugin(): Plugin { + return { + name: "solid-imager-e2e-virtual-thumbnails", + configureServer(server) { + server.middlewares.use(async (request, response, next) => { + const match = request.url?.match( + /^\/virtual-thumbnail\/[0-9a-f-]+-(256|512)\.webp(?:\?.*)?$/, + ); + if (!match) { + next(); + return; + } + + const width = Number(match[1]) as 256 | 512; + const thumbnail = await thumbnailBuffers.get(width); + if (!thumbnail) { + response.statusCode = 404; + response.end(); + return; + } + + response.statusCode = 200; + response.setHeader( + "Cache-Control", + "public, max-age=31536000, immutable", + ); + response.setHeader("Content-Length", thumbnail.byteLength); + response.setHeader("Content-Type", "image/webp"); + response.end(thumbnail); + }); + }, + }; +} + export default defineConfig({ root: galleryRoot, resolve: { @@ -20,5 +91,5 @@ export default defineConfig({ allow: [workspaceRoot], }, }, - plugins: [solidPlugin(), tailwindcss()], + plugins: [virtualThumbnailPlugin(), solidPlugin(), tailwindcss()], }); diff --git a/apps/server/src/tests/e2e/v2-scroll-restoration.spec.ts b/apps/server/src/tests/e2e/v2-scroll-restoration.spec.ts new file mode 100644 index 000000000..ccb3f8473 --- /dev/null +++ b/apps/server/src/tests/e2e/v2-scroll-restoration.spec.ts @@ -0,0 +1,237 @@ +import type { Page } from "@playwright/test"; +import { E2E_SOURCE_ID, E2E_SOURCE_NAME } from "./support/fixture"; +import { expect, test, waitForAppHydration } from "./support/test"; + +const searchEndpoint = "**/api/rpc/media/search**"; + +async function verifyRestoredScrollerDuringFastPageFetch( + page: Page, + entryPath: string, + heading: string, + scrollerSelector: string, + returnMethod: "browser" | "ui", +): Promise { + await page.setViewportSize({ width: 1280, height: 633 }); + + let delaySearchRequests = false; + let delayedNextPageRequestCount = 0; + await page.addInitScript(() => { + sessionStorage.removeItem("current-all"); + sessionStorage.removeItem("solid-imager-scroll-positions"); + sessionStorage.removeItem("v2:media-return"); + }); + await page.route(searchEndpoint, async (route) => { + if (delaySearchRequests && route.request().method() === "POST") { + if ((route.request().postData() ?? "").includes('"offset":200')) { + delayedNextPageRequestCount += 1; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + await route.continue(); + }); + + await page.goto(entryPath); + await waitForAppHydration(page); + await expect(page.getByText(heading, { exact: true }).last()).toBeVisible(); + + const scroller = page.locator(scrollerSelector); + await expect(scroller.locator("[data-media-id]").first()).toBeVisible(); + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight)) + .toBeGreaterThan(2000); + await scroller.hover(); + await page.mouse.wheel(0, 1500); + await expect + .poll(() => scroller.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(1000); + await page.waitForTimeout(300); + await expect + .poll(() => scroller.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(1000); + + const mediaId = await scroller.evaluate((element) => { + const bounds = element.getBoundingClientRect(); + return [...element.querySelectorAll("[data-media-id]")].find( + (item) => { + const itemBounds = item.getBoundingClientRect(); + return itemBounds.bottom > bounds.top && itemBounds.top < bounds.bottom; + }, + )?.dataset.mediaId; + }); + expect(mediaId).toBeDefined(); + if (!mediaId) throw new Error("A visible media item was not found"); + const mediaFileName = await page + .locator(`[data-media-id="${mediaId}"] img`) + .getAttribute("alt"); + expect(mediaFileName).toBeTruthy(); + if (!mediaFileName) + throw new Error("The visible media item has no file name"); + + await page.locator(`[data-media-id="${mediaId}"]`).click(); + await expect(page).toHaveURL(/\/v2\/sources\/[^/]+\/[^/]+$/); + await expect( + page.getByRole("heading", { name: mediaFileName, exact: true }), + ).toBeVisible(); + + if (returnMethod === "browser") { + await page.goBack(); + } else { + await page.getByRole("button", { name: "一覧に戻る", exact: true }).click(); + } + await expect.poll(() => new URL(page.url()).pathname).toBe(entryPath); + await waitForAppHydration(page); + await expect(scroller.locator("[data-media-id]").first()).toBeVisible(); + await expect + .poll(() => scroller.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(1000); + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); + + await page.evaluate((selector) => { + const main = document.querySelector("#v2-main-content"); + const initialScroller = document.querySelector(selector); + if (!main || !initialScroller) { + throw new Error("v2 media scroll containers were not found"); + } + + const probe = { + initialMain: main, + initialScroller, + removedSections: 0, + scrollTops: [] as number[], + }; + const observer = new MutationObserver((records) => { + for (const record of records) { + if (record.target !== main) continue; + probe.removedSections += [...record.removedNodes].filter( + (node) => node instanceof HTMLElement && node.tagName === "SECTION", + ).length; + } + }); + observer.observe(main, { childList: true }); + window.addEventListener( + "scroll", + (event) => { + if (event.target instanceof HTMLElement) { + probe.scrollTops.push(event.target.scrollTop); + } + }, + true, + ); + Object.assign(window, { __v2ScrollProbe: probe }); + }, scrollerSelector); + + const heightBeforeFetch = await scroller.evaluate( + (element) => element.scrollHeight, + ); + const nextPageResponsePromise = page.waitForResponse( + (response) => + response.url().includes("/api/rpc/media/search") && + (response.request().postData() ?? "").includes('"offset":200'), + { timeout: 10_000 }, + ); + delaySearchRequests = true; + await scroller.hover(); + await page.mouse.wheel(0, 1800); + await expect.poll(() => delayedNextPageRequestCount).toBeGreaterThan(0); + await page.waitForTimeout(100); + + const probeDuringFetch = await page.evaluate((selector) => { + const probe = ( + window as Window & { + __v2ScrollProbe?: { + initialMain: Element; + initialScroller: Element; + removedSections: number; + scrollTops: number[]; + }; + } + ).__v2ScrollProbe; + const currentScroller = document.querySelector(selector); + return { + scrollEventCount: probe?.scrollTops.length ?? 0, + removedSections: probe?.removedSections ?? -1, + mainWasReplaced: + probe?.initialMain !== document.querySelector("#v2-main-content"), + scrollerWasReplaced: probe?.initialScroller !== currentScroller, + minObservedScrollTop: probe?.scrollTops.length + ? Math.min(...probe.scrollTops) + : -1, + finalScrollTop: + currentScroller instanceof HTMLElement ? currentScroller.scrollTop : -1, + }; + }, scrollerSelector); + + expect(probeDuringFetch.removedSections).toBe(0); + expect(probeDuringFetch.scrollEventCount).toBeGreaterThan(0); + expect(probeDuringFetch.mainWasReplaced).toBe(false); + expect(probeDuringFetch.scrollerWasReplaced).toBe(false); + expect(probeDuringFetch.minObservedScrollTop).toBeGreaterThan(0); + expect(probeDuringFetch.finalScrollTop).toBeGreaterThan(1000); + + const nextPageResponse = await nextPageResponsePromise; + expect(nextPageResponse.status()).toBe(200); + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight), { + timeout: 10_000, + }) + .toBeGreaterThan(heightBeforeFetch); + const probeAfterFetch = await page.evaluate((selector) => { + const probe = ( + window as Window & { + __v2ScrollProbe?: { + initialMain: Element; + initialScroller: Element; + removedSections: number; + }; + } + ).__v2ScrollProbe; + const currentScroller = document.querySelector(selector); + return { + removedSections: probe?.removedSections ?? -1, + mainWasReplaced: + probe?.initialMain !== document.querySelector("#v2-main-content"), + scrollerWasReplaced: probe?.initialScroller !== currentScroller, + finalScrollTop: + currentScroller instanceof HTMLElement ? currentScroller.scrollTop : -1, + }; + }, scrollerSelector); + expect(probeAfterFetch.removedSections).toBe(0); + expect(probeAfterFetch.mainWasReplaced).toBe(false); + expect(probeAfterFetch.scrollerWasReplaced).toBe(false); + expect(probeAfterFetch.finalScrollTop).toBeGreaterThan(1000); +} + +const restorationCases = [ + { + name: "v2 search", + entryPath: "/v2/search", + heading: "すべてのメディア", + scrollerSelector: '[data-media-scroll="v2-search"]', + }, + { + name: "v2 source media", + entryPath: `/v2/sources/${E2E_SOURCE_ID}`, + heading: E2E_SOURCE_NAME, + scrollerSelector: `[data-media-scroll="${E2E_SOURCE_ID}"]`, + }, +] as const; +for (const restorationCase of restorationCases) { + for (const returnMethod of ["browser", "ui"] as const) { + test(`${restorationCase.name} keeps its restored scroller mounted during a fast page fetch via ${returnMethod} back`, async ({ + page, + }) => { + await verifyRestoredScrollerDuringFastPageFetch( + page, + restorationCase.entryPath, + restorationCase.heading, + restorationCase.scrollerSelector, + returnMethod, + ); + }); + } +} diff --git a/apps/server/src/tests/unit/application/services/job-dispatch-service.test.ts b/apps/server/src/tests/unit/application/services/job-dispatch-service.test.ts index 975f4c468..ce04443d5 100644 --- a/apps/server/src/tests/unit/application/services/job-dispatch-service.test.ts +++ b/apps/server/src/tests/unit/application/services/job-dispatch-service.test.ts @@ -3,6 +3,7 @@ import type { Job as DbJob } from "~/infrastructure/db/schema"; const processBatchCcipDispatchJob = vi.fn(); const processBulkTaggingDispatchJob = vi.fn(); +const processThumbnailGenerationJob = vi.fn(); vi.doMock("~/infrastructure/jobs/ccip-jobs", () => ({ processBatchCcipDispatchJob, @@ -13,10 +14,20 @@ vi.doMock("~/infrastructure/jobs/tagging-jobs", () => ({ processAutoTaggingJob: vi.fn(), })); -const { processJob } = await import( +vi.doMock("~/infrastructure/jobs/thumbnails", () => ({ + deleteThumbnail: vi.fn(), + processThumbnailGenerationJob, +})); + +const { configureThumbnailJobHandlers, processJob } = await import( "~/application/services/job-dispatch-service" ); +configureThumbnailJobHandlers({ + deleteThumbnail: vi.fn(), + processThumbnailGenerationJob, +}); + function createJob( type: DbJob["type"], mediaSourceId: string | null, @@ -40,6 +51,7 @@ describe("processJob", () => { beforeEach(() => { processBatchCcipDispatchJob.mockReset(); processBulkTaggingDispatchJob.mockReset(); + processThumbnailGenerationJob.mockReset(); }); it("allows batch_ccip_dispatch without mediaSourceId", async () => { @@ -65,4 +77,15 @@ describe("processJob", () => { expect(processBatchCcipDispatchJob).not.toHaveBeenCalled(); expect(processBulkTaggingDispatchJob).not.toHaveBeenCalled(); }); + + it("dispatches generate_thumbnail jobs", async () => { + const job = createJob( + "generate_thumbnail", + "22222222-2222-4222-8222-222222222222", + ); + processThumbnailGenerationJob.mockResolvedValueOnce(undefined); + + await expect(processJob(job)).resolves.toBeUndefined(); + expect(processThumbnailGenerationJob).toHaveBeenCalledWith(job); + }); }); diff --git a/apps/server/src/tests/unit/infrastructure/jobs/job-worker.test.ts b/apps/server/src/tests/unit/infrastructure/jobs/job-worker.test.ts index 5b0acc659..81e7a44ea 100644 --- a/apps/server/src/tests/unit/infrastructure/jobs/job-worker.test.ts +++ b/apps/server/src/tests/unit/infrastructure/jobs/job-worker.test.ts @@ -75,6 +75,9 @@ describe("JobWorker", () => { // When excluding AI types, return normal jobs (jobRepo.claimPending as any).mockImplementation( (limit: number, options: any) => { + if (options?.includeTypes?.includes("generate_thumbnail")) { + return Promise.resolve([]); + } if (options?.excludeTypes) { return Promise.resolve(normalJobs.slice(0, limit)); } @@ -89,7 +92,11 @@ describe("JobWorker", () => { expect(jobRepo.claimPending).toHaveBeenCalledWith( 2, expect.objectContaining({ - excludeTypes: ["auto_tagging", "extract_ccip_vector"], + excludeTypes: [ + "auto_tagging", + "extract_ccip_vector", + "generate_thumbnail", + ], }), ); expect(processor).toHaveBeenCalledTimes(2); @@ -115,6 +122,9 @@ describe("JobWorker", () => { // Mock claimPending (jobRepo.claimPending as any).mockImplementation( (limit: number, options: any) => { + if (options?.includeTypes?.includes("generate_thumbnail")) { + return Promise.resolve([]); + } if (options?.includeTypes) { return Promise.resolve(aiJobs.slice(0, limit)); } @@ -163,6 +173,9 @@ describe("JobWorker", () => { // Mock claimPending (jobRepo.claimPending as any).mockImplementation( (limit: number, options: any) => { + if (options?.includeTypes?.includes("generate_thumbnail")) { + return Promise.resolve([]); + } if (options?.includeTypes) { // AI request return Promise.resolve([aiJob].slice(0, limit)); @@ -188,7 +201,11 @@ describe("JobWorker", () => { expect(jobRepo.claimPending).toHaveBeenCalledWith( 2, expect.objectContaining({ - excludeTypes: ["auto_tagging", "extract_ccip_vector"], + excludeTypes: [ + "auto_tagging", + "extract_ccip_vector", + "generate_thumbnail", + ], }), ); @@ -216,6 +233,9 @@ describe("JobWorker", () => { (jobRepo.claimPending as any).mockImplementation( (limit: number, options: any) => { + if (options?.includeTypes?.includes("generate_thumbnail")) { + return Promise.resolve([]); + } if (options?.excludeTypes) { return Promise.resolve([customJob].slice(0, limit)); } @@ -271,6 +291,9 @@ describe("JobWorker", () => { (jobRepo.claimPending as any).mockImplementation( (limit: number, options: any) => { + if (options?.includeTypes?.includes("generate_thumbnail")) { + return Promise.resolve([]); + } if (options?.excludeTypes) { return Promise.resolve( [ @@ -299,6 +322,41 @@ describe("JobWorker", () => { await vi.runOnlyPendingTimersAsync(); }); + it("processes at most one thumbnail generation job at a time", async () => { + let resolveThumbnail: () => void = () => {}; + processor = vi.fn( + () => + new Promise((resolve) => { + resolveThumbnail = resolve; + }), + ); + worker = new JobWorker(jobRepo, processor); + const thumbnailJob = { + id: "thumbnail-1", + type: "generate_thumbnail", + status: "pending", + } as Job; + (jobRepo.claimPending as any).mockImplementation( + (_limit: number, options: any) => + Promise.resolve( + options?.includeTypes?.includes("generate_thumbnail") + ? [thumbnailJob] + : [], + ), + ); + + worker.start(); + await vi.advanceTimersByTimeAsync(TimerDelay); + await vi.advanceTimersByTimeAsync(1000); + + expect(processor).toHaveBeenCalledTimes(1); + expect(jobRepo.claimPending).toHaveBeenCalledWith(1, { + includeTypes: ["generate_thumbnail"], + }); + resolveThumbnail(); + await vi.runOnlyPendingTimersAsync(); + }); + it("should pass active LanceDB sync source IDs to claimPending for exclusion", async () => { worker.updateConfig({ jobs: { concurrency: 3, aiConcurrency: 1, pollIntervalMs: 1000 }, diff --git a/apps/tauri/src/components/media/media-grid-item.tsx b/apps/tauri/src/components/media/media-grid-item.tsx index efc6f1009..75c1edd62 100644 --- a/apps/tauri/src/components/media/media-grid-item.tsx +++ b/apps/tauri/src/components/media/media-grid-item.tsx @@ -1,11 +1,15 @@ import type { Media } from "@solid-imager/core/domain/media/schemas"; -import { MediaGridItem as SharedMediaGridItem } from "@solid-imager/ui/media-grid-item"; +import { + type MediaGridImageLoadPolicy, + MediaGridItem as SharedMediaGridItem, +} from "@solid-imager/ui/media-grid-item"; import { Link } from "@tanstack/solid-router"; import { ThumbnailImage } from "./thumbnail-image"; type MediaGridItemProps = { linkPrefix?: string; media: Media; + imageLoadPolicy?: MediaGridImageLoadPolicy; onContextMenu?: (event: MouseEvent) => void; priority?: boolean; sourceRootPath?: string; @@ -30,15 +34,19 @@ export function MediaGridItem(props: MediaGridItemProps) { )} linkPrefix={props.linkPrefix} media={props.media} + imageLoadPolicy={props.imageLoadPolicy} onContextMenu={props.onContextMenu} priority={props.priority} renderThumbnail={(thumbnailProps) => ( diff --git a/apps/tauri/src/components/media/thumbnail-image.tsx b/apps/tauri/src/components/media/thumbnail-image.tsx index 5aacf53c2..52f3ad4a4 100644 --- a/apps/tauri/src/components/media/thumbnail-image.tsx +++ b/apps/tauri/src/components/media/thumbnail-image.tsx @@ -6,18 +6,23 @@ import { import { type BuildThumbnailUrlArgs, createHttpThumbnailSource, + type ThumbnailRequestSize, } from "@solid-imager/ui/thumbnail-source"; import { buildThumbnailUrl } from "~/infrastructure/media/thumbnail-runtime"; type ThumbnailImageProps = { alt: string; class?: string; + enabled?: boolean; fallback?: string; + fetchpriority?: "high" | "low" | "auto"; height?: number | null; loading?: "eager" | "lazy"; maxRetries?: number; media: MediaSafe; retryDelayMs?: number; + requestedSize?: ThumbnailRequestSize; + sizes?: string; sourceRootPath?: string; width?: number | null; }; @@ -29,18 +34,23 @@ function buildUrl(args: BuildThumbnailUrlArgs): string { export function ThumbnailImage(props: ThumbnailImageProps) { const source = createHttpThumbnailSource({ buildUrl, + defaultSize: props.requestedSize ?? (props.sizes ? 512 : undefined), maxRetries: props.maxRetries, mediaId: props.media.id, mediaSourceId: props.media.mediaSourceId, modifiedAt: props.media.modifiedAt, + responsiveSizes: props.sizes ? [256, 512] : undefined, retryDelayMs: props.retryDelayMs, }); const sharedProps: SharedThumbnailImageProps = { alt: props.alt, class: props.class, + enabled: props.enabled, fallback: props.fallback, + fetchpriority: props.fetchpriority, height: props.height, loading: props.loading, + sizes: props.sizes, source, width: props.width, }; diff --git a/apps/tauri/src/infrastructure/api-clients/thumbnails-api.ts b/apps/tauri/src/infrastructure/api-clients/thumbnails-api.ts new file mode 100644 index 000000000..7e624e129 --- /dev/null +++ b/apps/tauri/src/infrastructure/api-clients/thumbnails-api.ts @@ -0,0 +1,13 @@ +import { orpc } from "~/infrastructure/api-clients/orpc-client"; + +export function startThumbnailWarmup(input: { + mediaSourceId: string; + missingOnly: true; + size: 256; +}) { + return orpc.thumbnails.generate({ + sourceId: input.mediaSourceId, + missingOnly: input.missingOnly, + size: input.size, + }); +} diff --git a/apps/tauri/src/infrastructure/media/thumbnail-runtime.ts b/apps/tauri/src/infrastructure/media/thumbnail-runtime.ts index dc6b68bd3..db5b6484f 100644 --- a/apps/tauri/src/infrastructure/media/thumbnail-runtime.ts +++ b/apps/tauri/src/infrastructure/media/thumbnail-runtime.ts @@ -11,11 +11,16 @@ export function buildThumbnailUrl(args: { cacheKey: number; mediaId: string; mediaSourceId: string; + size?: 256 | 512; }): string { const base = buildAbsoluteUrl( `/api/sources/${args.mediaSourceId}/thumbnail/${args.mediaId}`, ); - return args.cacheKey ? `${base}?t=${args.cacheKey}` : base; + const query = new URLSearchParams(); + if (args.size) query.set("size", String(args.size)); + if (args.cacheKey) query.set("t", String(args.cacheKey)); + const search = query.toString(); + return search ? `${base}?${search}` : base; } export function buildMediaContentUrl( diff --git a/apps/tauri/src/routes/manager.tsx b/apps/tauri/src/routes/manager.tsx index b83ed67b7..c1043d511 100644 --- a/apps/tauri/src/routes/manager.tsx +++ b/apps/tauri/src/routes/manager.tsx @@ -31,6 +31,7 @@ import { deleteProject, updateProject, } from "~/infrastructure/api-clients/projects-api"; +import { startThumbnailWarmup } from "~/infrastructure/api-clients/thumbnails-api"; import { allCharactersQueryOptions, allIpsQueryOptions, @@ -61,6 +62,7 @@ const managerActions = { startBatchCcipExtraction, findDuplicateMedia, bulkDeleteMedia, + startThumbnailWarmup, }; export const Route = createFileRoute("/manager")({ diff --git a/apps/tauri/src/routes/search.tsx b/apps/tauri/src/routes/search.tsx index 9d1ce748b..82a9b738d 100644 --- a/apps/tauri/src/routes/search.tsx +++ b/apps/tauri/src/routes/search.tsx @@ -86,9 +86,11 @@ function SearchRoute() { onSelectSource={(id) => setSearchState("selectedSource", id)} page={page} presetClient={PresetClient} - renderMediaItem={(media) => ( + renderMediaItem={(media, options) => ( )} diff --git a/apps/tauri/src/routes/sources/$mediaSourceId/components/source-media-page.tsx b/apps/tauri/src/routes/sources/$mediaSourceId/components/source-media-page.tsx index a7b9416e9..44faeb412 100644 --- a/apps/tauri/src/routes/sources/$mediaSourceId/components/source-media-page.tsx +++ b/apps/tauri/src/routes/sources/$mediaSourceId/components/source-media-page.tsx @@ -78,10 +78,12 @@ export function SourceMediaPage() { ipsQueryOptions={allIpsQueryOptions} charactersQueryOptions={allCharactersQueryOptions} authorsQueryOptions={allAuthorsQueryOptions} - renderItem={(media, { onContextMenu }) => ( + renderItem={(media, { imageLoadPolicy, onContextMenu, priority }) => ( )} diff --git a/packages/application/src/ports/thumbnail-service.ts b/packages/application/src/ports/thumbnail-service.ts index a19e3c1ce..5649ccb7a 100644 --- a/packages/application/src/ports/thumbnail-service.ts +++ b/packages/application/src/ports/thumbnail-service.ts @@ -1,13 +1,19 @@ +import type { + GenerateThumbnailsResponse, + ThumbnailSize, +} from "@solid-imager/core/domain/thumbnails/schemas"; + export interface IThumbnailService { getMediaThumbnailUrl( mediaSourceId: string, mediaId: string, - size?: number, + size?: ThumbnailSize, ): string; startThumbnailGeneration( mediaSourceId: string, - ): Promise<{ success: boolean; count: number }>; + options: { size: ThumbnailSize; missingOnly: boolean }, + ): Promise; clearThumbnailCache(mediaSourceId: string): Promise<{ success: boolean }>; } diff --git a/packages/application/src/services/thumbnail-service.ts b/packages/application/src/services/thumbnail-service.ts index db7214c31..6ded5a469 100644 --- a/packages/application/src/services/thumbnail-service.ts +++ b/packages/application/src/services/thumbnail-service.ts @@ -1,10 +1,17 @@ +import type { + GenerateThumbnailsResponse, + ThumbnailSize, +} from "@solid-imager/core/domain/thumbnails/schemas"; import type { IFileSystem } from "@solid-imager/core/interfaces/file-system"; import type { IThumbnailService } from "../ports/thumbnail-service"; export type ThumbnailServiceDeps = { fileSystem: IFileSystem; thumbnailCacheDir: string; - generateThumbnailsForSource: (mediaSourceId: string) => Promise; + generateThumbnailsForSource: ( + mediaSourceId: string, + options: { size: ThumbnailSize; missingOnly: boolean }, + ) => Promise<{ count: number; jobId?: string }>; }; export class ThumbnailServiceImpl implements IThumbnailService { @@ -21,7 +28,7 @@ export class ThumbnailServiceImpl implements IThumbnailService { getMediaThumbnailUrl( mediaSourceId: string, mediaId: string, - size?: number, + size?: ThumbnailSize, ): string { let url = `/api/sources/${mediaSourceId}/thumbnail/${mediaId}`; if (size) { @@ -32,9 +39,21 @@ export class ThumbnailServiceImpl implements IThumbnailService { async startThumbnailGeneration( mediaSourceId: string, - ): Promise<{ success: boolean; count: number }> { - const count = await this.generateThumbnailsForSource(mediaSourceId); - return { success: true, count }; + options: { size: ThumbnailSize; missingOnly: boolean }, + ): Promise { + const result = await this.generateThumbnailsForSource( + mediaSourceId, + options, + ); + return { + success: true, + count: result.count, + jobId: result.jobId, + message: + result.count > 0 + ? `Queued ${result.count} thumbnail job(s)` + : "No thumbnails need generation", + }; } async clearThumbnailCache( diff --git a/packages/core/src/domain/contract/thumbnails.contract.ts b/packages/core/src/domain/contract/thumbnails.contract.ts index a357e87aa..eb36b18f0 100644 --- a/packages/core/src/domain/contract/thumbnails.contract.ts +++ b/packages/core/src/domain/contract/thumbnails.contract.ts @@ -1,8 +1,14 @@ import { oc } from "@orpc/contract"; import { z } from "zod"; +import { + generateThumbnailsRequestSchema, + generateThumbnailsResponseSchema, +} from "../thumbnails/schemas"; export const thumbnailsContract = { - generate: oc.input(z.object({ sourceId: z.string().uuid() })), + generate: oc + .input(generateThumbnailsRequestSchema) + .output(generateThumbnailsResponseSchema), clear: oc.input(z.object({ sourceId: z.string().uuid() })), }; diff --git a/packages/core/src/domain/sources/events.ts b/packages/core/src/domain/sources/events.ts index 041a3ac13..b0c4ef214 100644 --- a/packages/core/src/domain/sources/events.ts +++ b/packages/core/src/domain/sources/events.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { thumbnailSizeSchema } from "../thumbnails/schemas"; const sourceScopedEventSchema = z.object({ mediaSourceId: z.string().uuid().optional(), @@ -43,6 +44,7 @@ export const thumbnailGeneratedEventSchema = z.object({ mediaId: z.string().uuid(), filePath: z.string().optional(), timestamp: z.string().optional(), + size: thumbnailSizeSchema.optional(), }); export type ThumbnailGeneratedEvent = z.infer< typeof thumbnailGeneratedEventSchema diff --git a/packages/core/src/domain/thumbnails/schemas.ts b/packages/core/src/domain/thumbnails/schemas.ts new file mode 100644 index 000000000..59e8216c7 --- /dev/null +++ b/packages/core/src/domain/thumbnails/schemas.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +export const thumbnailSizeSchema = z.union([z.literal(256), z.literal(512)]); +export type ThumbnailSize = z.infer; + +export const generateThumbnailsRequestSchema = z.object({ + sourceId: z.string().uuid(), + size: thumbnailSizeSchema.optional().default(512), + missingOnly: z.boolean().optional().default(false), +}); +export type GenerateThumbnailsRequest = z.infer< + typeof generateThumbnailsRequestSchema +>; + +export const generateThumbnailsResponseSchema = z.object({ + success: z.boolean(), + count: z.number().int().nonnegative(), + jobId: z.string().uuid().optional(), + message: z.string(), +}); +export type GenerateThumbnailsResponse = z.infer< + typeof generateThumbnailsResponseSchema +>; + +export const generateThumbnailJobPayloadSchema = z.object({ + mediaId: z.string().uuid(), + size: thumbnailSizeSchema, +}); +export type GenerateThumbnailJobPayload = z.infer< + typeof generateThumbnailJobPayloadSchema +>; diff --git a/packages/db/src/repositories/job-repository.test.ts b/packages/db/src/repositories/job-repository.test.ts index 2e188ebcf..6888a0142 100644 --- a/packages/db/src/repositories/job-repository.test.ts +++ b/packages/db/src/repositories/job-repository.test.ts @@ -25,8 +25,14 @@ describe("JobRepository", () => { ], }), update: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), set: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), + insert: vi.fn().mockReturnThis(), + values: vi.fn().mockReturnThis(), + onConflictDoNothing: vi.fn().mockReturnThis(), returning: vi .fn() .mockResolvedValue([{ id: "11111111-1111-4111-8111-111111111111" }]), @@ -90,6 +96,32 @@ describe("JobRepository", () => { expect(mockExecutor.update).toHaveBeenCalledOnce(); }); + it("does not requeue thumbnail batch parent progress records", async () => { + await repository.requeueStaleInProgress( + new Date("2026-06-23T00:00:00.000Z"), + ); + + const where = extractSqlText(mockExecutor.where.mock.calls[0]?.[0]); + expect(where).toContain("thumbnail_generation_parent"); + }); + + it("deduplicates active thumbnail jobs by source, media and size", async () => { + mockExecutor.returning.mockResolvedValueOnce([]); + + const created = await repository.createIfUnique({ + type: "generate_thumbnail", + mediaSourceId: "22222222-2222-4222-8222-222222222222", + payload: { + mediaId: "33333333-3333-4333-8333-333333333333", + size: 256, + }, + }); + + expect(created).toBeNull(); + expect(mockExecutor.insert).toHaveBeenCalledOnce(); + expect(mockExecutor.onConflictDoNothing).toHaveBeenCalledOnce(); + }); + it("clears stale result and error markers when requeueing", async () => { await repository.requeueStaleInProgress( new Date("2026-06-23T00:00:00.000Z"), diff --git a/packages/db/src/repositories/job-repository.ts b/packages/db/src/repositories/job-repository.ts index 9b145d3a0..87aff7efb 100644 --- a/packages/db/src/repositories/job-repository.ts +++ b/packages/db/src/repositories/job-repository.ts @@ -5,6 +5,7 @@ import type { NewJob, } from "@solid-imager/core/domain/repositories/job-repository"; import { batchParentPayloadSchema } from "@solid-imager/core/domain/tagging/schemas"; +import { generateThumbnailJobPayloadSchema } from "@solid-imager/core/domain/thumbnails/schemas"; import { isJobStatus } from "@solid-imager/core/utils/type-guards"; import { and, @@ -68,6 +69,16 @@ export function createJobRepository( }, async createIfUnique(job: NewJob): Promise { + if (job.type === "generate_thumbnail" && job.mediaSourceId) { + generateThumbnailJobPayloadSchema.parse(job.payload); + const [created] = await db() + .insert(jobs) + .values(job) + .onConflictDoNothing() + .returning(); + return created ? mapJob(created) : null; + } + if (job.type === "sync_lancedb_delta" && job.mediaSourceId) { const [pending] = await db() .select() @@ -341,7 +352,11 @@ export function createJobRepository( and( eq(jobs.status, "in_progress"), lt(jobs.updatedAt, olderThan), - notInArray(jobs.type, ["batch_ccip_parent", "bulk_tagging_parent"]), + notInArray(jobs.type, [ + "batch_ccip_parent", + "bulk_tagging_parent", + "thumbnail_generation_parent", + ]), ), ) .returning(); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 23b1d7c5f..ea45a6b27 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1073,6 +1073,17 @@ export const jobs = pgTable( AND ${table.type} IN ('sync_lancedb', 'sync_lancedb_full', 'sync_lancedb_delta') AND ${table.mediaSourceId} IS NOT NULL`, ), + activeThumbnailUniqueIndex: uniqueIndex("uq_jobs_active_thumbnail") + .on( + table.mediaSourceId, + sql`(${table.payload}->>'mediaId')`, + sql`(${table.payload}->>'size')`, + ) + .where( + sql`${table.type} = 'generate_thumbnail' + AND ${table.status} IN ('pending', 'in_progress') + AND ${table.mediaSourceId} IS NOT NULL`, + ), }), ); diff --git a/packages/ui/src/hooks/scroll-container.ts b/packages/ui/src/hooks/scroll-container.ts index cb54bf299..7d6b16752 100644 --- a/packages/ui/src/hooks/scroll-container.ts +++ b/packages/ui/src/hooks/scroll-container.ts @@ -1,3 +1,7 @@ +import type { Accessor } from "solid-js"; +import { createEffect, createSignal, onCleanup, onMount } from "solid-js"; +import { isServer } from "solid-js/web"; + export function resolveScrollContainer( selector: string | undefined, ): HTMLElement | null { @@ -26,3 +30,265 @@ export function currentScrollPosition( if (selector) return resolveScrollContainer(selector)?.scrollTop ?? null; return typeof window === "undefined" ? null : window.scrollY; } + +type ScrollMetrics = { + maximum: number; +}; + +function getScrollMetrics(selector: string | undefined): ScrollMetrics | null { + if (selector) { + const container = resolveScrollContainer(selector); + if (!container) return null; + return { + maximum: Math.max(0, container.scrollHeight - container.clientHeight), + }; + } + + if (typeof window === "undefined" || typeof document === "undefined") { + return null; + } + + return { + maximum: Math.max( + 0, + document.documentElement.scrollHeight - window.innerHeight, + ), + }; +} + +export type ScrollRestorationOptions = { + /** Identifies the result set whose position is being restored. */ + restoreKey: Accessor; + getPosition: (key: string) => number; + setPosition: (key: string, position: number) => void; + /** The result grid and its scroll container are ready to measure. */ + isReady: Accessor; + hasNextPage: Accessor; + isFetchingNextPage: Accessor; + fetchNextPage: () => Promise | unknown; + scrollContainerSelector?: string; +}; + +const MAX_RESTORE_PAGE_FETCHES = 100; +const RESTORE_SETTLE_DELAY_MS = 100; + +/** + * Restores a list's scroll position after its initial pages have rendered. + * + * Virtualized grids only expose the height of the pages they have loaded. When + * the saved position is beyond that height, fetch another page and retry after + * the grid grows instead of letting the browser clamp the scroll position. + */ +export function useScrollRestoration( + options: ScrollRestorationOptions, +): Accessor { + const [isRestored, setIsRestored] = createSignal(false); + let activeKey: string | undefined; + let cancelled = false; + let fetchCount = 0; + let fetchInFlight = false; + let frameId: number | undefined; + let settleTimer: ReturnType | undefined; + let restoreGeneration = 0; + let reconcileFrameId: number | undefined; + let secondReconcileFrameId: number | undefined; + + const requestRestore = () => { + if (frameId !== undefined || isServer) return; + frameId = requestAnimationFrame(() => { + frameId = undefined; + void attemptRestore(); + }); + }; + + const finishAt = (position: number, maximum: number) => { + const key = activeKey; + if (!key || cancelled) return; + const target = Math.min(Math.max(position, 0), maximum); + scrollToPosition(options.scrollContainerSelector, target); + if (target === 0) { + setIsRestored(true); + return; + } + + if (settleTimer !== undefined) clearTimeout(settleTimer); + settleTimer = setTimeout(() => { + settleTimer = undefined; + if (cancelled || !activeKey) return; + const latestMetrics = getScrollMetrics(options.scrollContainerSelector); + if (!latestMetrics) { + requestRestore(); + return; + } + scrollToPosition( + options.scrollContainerSelector, + Math.min(target, latestMetrics.maximum), + ); + setIsRestored(true); + }, RESTORE_SETTLE_DELAY_MS); + }; + + const attemptRestore = async () => { + const key = activeKey; + const generation = restoreGeneration; + if ( + !key || + cancelled || + isRestored() || + settleTimer !== undefined || + !options.isReady() || + generation !== restoreGeneration + ) { + return; + } + + const metrics = getScrollMetrics(options.scrollContainerSelector); + if (!metrics) { + requestRestore(); + return; + } + + const target = Math.max(0, options.getPosition(key)); + if (target <= metrics.maximum + 1) { + finishAt(target, metrics.maximum); + return; + } + + if (!options.hasNextPage() || fetchCount >= MAX_RESTORE_PAGE_FETCHES) { + finishAt(target, metrics.maximum); + return; + } + + if (options.isFetchingNextPage() || fetchInFlight) return; + + fetchCount += 1; + fetchInFlight = true; + try { + await options.fetchNextPage(); + } catch { + // A failed prefetch should not leave the route permanently blocked. + if (generation === restoreGeneration) { + const latestMetrics = getScrollMetrics(options.scrollContainerSelector); + if (latestMetrics) finishAt(target, latestMetrics.maximum); + } + } finally { + fetchInFlight = false; + if (!cancelled && !isRestored()) requestRestore(); + } + }; + + const savePosition = () => { + if (isServer) return; + if (!isRestored()) return; + const key = activeKey; + if (!key) return; + const position = currentScrollPosition(options.scrollContainerSelector); + if (position !== null) options.setPosition(key, position); + }; + + createEffect(() => { + const key = options.restoreKey(); + if (key !== activeKey) { + savePosition(); + activeKey = key; + cancelled = false; + fetchCount = 0; + if (settleTimer !== undefined) { + clearTimeout(settleTimer); + settleTimer = undefined; + } + restoreGeneration += 1; + setIsRestored(false); + } + + // Track pagination transitions so a completed fetch retries restoration. + options.hasNextPage(); + options.isFetchingNextPage(); + if (key && options.isReady() && !isRestored() && !cancelled) { + requestRestore(); + } + }); + + onMount(() => { + if ("scrollRestoration" in history) { + history.scrollRestoration = "manual"; + } + + const notifyScrollPosition = () => { + if (options.scrollContainerSelector) { + resolveScrollContainer(options.scrollContainerSelector)?.dispatchEvent( + new Event("scroll"), + ); + return; + } + window.dispatchEvent(new Event("scroll")); + }; + + const reconcileVirtualizerPosition = () => { + notifyScrollPosition(); + if (reconcileFrameId !== undefined) { + cancelAnimationFrame(reconcileFrameId); + } + if (secondReconcileFrameId !== undefined) { + cancelAnimationFrame(secondReconcileFrameId); + } + reconcileFrameId = requestAnimationFrame(() => { + reconcileFrameId = undefined; + notifyScrollPosition(); + secondReconcileFrameId = requestAnimationFrame(() => { + secondReconcileFrameId = undefined; + notifyScrollPosition(); + }); + }); + }; + + const cancelRestore = () => { + if (!isRestored()) { + cancelled = true; + restoreGeneration += 1; + if (frameId !== undefined) { + cancelAnimationFrame(frameId); + frameId = undefined; + } + if (settleTimer !== undefined) { + clearTimeout(settleTimer); + settleTimer = undefined; + } + // User input takes ownership of the scroll position. Mark the + // restoration complete so cleanup persists the position they chose + // instead of treating it as an unfinished restore. + setIsRestored(true); + // The virtualizer listens to native scroll events. A user event can + // cancel restoration before the browser changes scrollTop, leaving + // its previous (restored) range mounted at the top of the container. + // Re-notify it now and for the next two frames so it reconciles with + // the actual scroll position after pending layout work settles. + reconcileVirtualizerPosition(); + } + }; + window.addEventListener("pointerdown", cancelRestore, { passive: true }); + window.addEventListener("wheel", cancelRestore, { passive: true }); + window.addEventListener("touchstart", cancelRestore, { passive: true }); + window.addEventListener("keydown", cancelRestore); + onCleanup(() => { + window.removeEventListener("pointerdown", cancelRestore); + window.removeEventListener("wheel", cancelRestore); + window.removeEventListener("touchstart", cancelRestore); + window.removeEventListener("keydown", cancelRestore); + }); + }); + + onCleanup(() => { + cancelled = true; + restoreGeneration += 1; + if (frameId !== undefined) cancelAnimationFrame(frameId); + if (settleTimer !== undefined) clearTimeout(settleTimer); + if (reconcileFrameId !== undefined) cancelAnimationFrame(reconcileFrameId); + if (secondReconcileFrameId !== undefined) { + cancelAnimationFrame(secondReconcileFrameId); + } + savePosition(); + }); + + return isRestored; +} diff --git a/packages/ui/src/hooks/use-current-search-persistence.test.ts b/packages/ui/src/hooks/use-current-search-persistence.test.ts index 2db333f31..00107d949 100644 --- a/packages/ui/src/hooks/use-current-search-persistence.test.ts +++ b/packages/ui/src/hooks/use-current-search-persistence.test.ts @@ -130,6 +130,13 @@ describe("useCurrentSearchPersistence", () => { expect(searchState.selectedSource).toBe("source-1"); }); + it("persists the initial state before the debounce expires", async () => { + mountPersistence("all"); + await flushMicrotasks(); + + expect(sessionStorage.getItem("current-all")).toContain('"mode":"simple"'); + }); + it("restores the latest source when the source accessor changes", async () => { const [sourceId, setSourceId] = createSignal("source-a"); sessionStorage.setItem( @@ -185,6 +192,19 @@ describe("useCurrentSearchPersistence", () => { expect(searchState.selectedSource).toBe("saved-source"); }); + it("preserves the current scroll position while restoring search state", async () => { + setSearchState("scrollY", 1840); + sessionStorage.setItem( + "current-all", + JSON.stringify(createPersistedSimpleState("saved query")), + ); + + mountPersistence("all"); + await flushMicrotasks(); + + expect(searchState.scrollY).toBe(1840); + }); + it("does not inherit a source for legacy vector sessions", async () => { setSearchState("selectedSource", "stale-source"); sessionStorage.setItem( diff --git a/packages/ui/src/hooks/use-current-search-persistence.ts b/packages/ui/src/hooks/use-current-search-persistence.ts index a8fed356e..400fee444 100644 --- a/packages/ui/src/hooks/use-current-search-persistence.ts +++ b/packages/ui/src/hooks/use-current-search-persistence.ts @@ -47,9 +47,10 @@ function applyPreset( if (!shouldApply()) { return; } + const preservedScrollY = searchState.scrollY; resetSearchState(); loadPreset(preset); - setSearchState("selectedSource", selectedSource); + setSearchState({ selectedSource, scrollY: preservedScrollY }); if (clearActivePreset) { setSearchState("activePresetId", null); } @@ -102,6 +103,7 @@ function restoreCurrentSearchState( if (!shouldApply()) { return; } + const preservedScrollY = searchState.scrollY; const selectedSource = typeof current.selectedSource === "string" ? current.selectedSource : ""; resetSearchState(); @@ -116,6 +118,7 @@ function restoreCurrentSearchState( current.similarityTopK === 20 || current.similarityTopK === 100 ? current.similarityTopK : 50, + scrollY: preservedScrollY, }); return; } @@ -149,6 +152,7 @@ export function useCurrentSearchPersistence( const [isRestored, setIsRestored] = createSignal(false); let debounceTimer: ReturnType | null = null; let restoreVersion = 0; + let lastPersistedPresetName: string | null = null; const getSourceId = () => typeof sourceId === "function" ? sourceId() : sourceId; @@ -206,28 +210,37 @@ export function useCurrentSearchPersistence( return; } - const condition = getSearchCondition() || { - type: "group" as const, - operator: "and" as const, - children: [], - }; - const presetData = { - value: condition, - selectedSource: searchState.selectedSource, - sort: searchState.sortBy, - order: searchState.sortOrder, - mode: searchState.mode, - similarityAnchorMediaId: searchState.similarityAnchorMediaId, - similarityTopK: searchState.similarityTopK, - }; - - debounceTimer = setTimeout(() => { + const persistCurrentState = () => { + const condition = getSearchCondition() || { + type: "group" as const, + operator: "and" as const, + children: [], + }; + const presetData = { + value: condition, + selectedSource: searchState.selectedSource, + sort: searchState.sortBy, + order: searchState.sortOrder, + mode: searchState.mode, + similarityAnchorMediaId: searchState.similarityAnchorMediaId, + similarityTopK: searchState.similarityTopK, + }; try { sessionStorage.setItem(presetName, JSON.stringify(presetData)); + lastPersistedPresetName = presetName; } catch { // Persistence errors must not disrupt the UI. } - }, DEBOUNCE_MS); + }; + + // Persist the initial state synchronously so navigating to a media detail + // before the debounce expires cannot reset the list on return. + if (lastPersistedPresetName !== presetName) { + persistCurrentState(); + return; + } + + debounceTimer = setTimeout(persistCurrentState, DEBOUNCE_MS); }); onCleanup(() => { diff --git a/packages/ui/src/hooks/use-manager-page.ts b/packages/ui/src/hooks/use-manager-page.ts index b16af3465..0c233f9ed 100644 --- a/packages/ui/src/hooks/use-manager-page.ts +++ b/packages/ui/src/hooks/use-manager-page.ts @@ -27,6 +27,7 @@ export type ManagerEntityType = | "characters" | "tagging" | "vectors" + | "thumbnails" | "duplicates"; export type ManagerEntity = Project | Ip | Character; @@ -36,12 +37,14 @@ export type ManagerFormData = { ipIds?: string[]; }; -export type StartBatchTaggingResult = { +export type StartBatchJobResult = { success: boolean; jobId?: string; message: string; }; +export type StartBatchTaggingResult = StartBatchJobResult; + export type ManagerPageQueries = { projects: Accessor; ips: Accessor; @@ -81,13 +84,16 @@ export type ManagerPageActions = { force: boolean; mediaSourceId?: string; }) => Promise; + startThumbnailWarmup: (input: { + mediaSourceId: string; + missingOnly: true; + size: 256; + }) => Promise; findDuplicateMedia: ( mediaSourceId?: string, ) => Promise<{ groups: DuplicateGroup[] }>; bulkDeleteMedia: (sourceId: string, mediaIds: string[]) => Promise; - invalidate: ( - entityType: Exclude, - ) => void; + invalidate: (entityType: "projects" | "ips" | "characters") => void; }; export type ManagerPageMutationActions = Omit; @@ -173,6 +179,7 @@ export type UseManagerPageResult = { handleScan: () => Promise; handleStartBatchTagging: () => Promise; handleStartBatchCcipExtraction: () => Promise; + handleStartThumbnailWarmup: () => Promise; jobHandlers: ManagerJobHandlers; // Duplicates tab duplicateSourceId: Accessor; @@ -208,9 +215,13 @@ function resetForm(setFormData: Setter) { function activeCrudTab( activeTab: ManagerEntityType, -): Exclude | null { +): Exclude< + ManagerEntityType, + "tagging" | "vectors" | "thumbnails" | "duplicates" +> | null { return activeTab === "tagging" || activeTab === "vectors" || + activeTab === "thumbnails" || activeTab === "duplicates" ? null : activeTab; @@ -277,7 +288,7 @@ export function useManagerPage( createEffect(() => { const tab = activeTab(); - if (tab !== "tagging" && tab !== "vectors") { + if (tab !== "tagging" && tab !== "vectors" && tab !== "thumbnails") { return; } @@ -510,6 +521,40 @@ export function useManagerPage( } }; + const handleStartThumbnailWarmup = async () => { + const mediaSourceId = selectedSourceId(); + if (!mediaSourceId) { + toast.error("Select a media source first."); + return; + } + + try { + setTaggingStatus("Scanning for missing 256px thumbnails..."); + setJobProgress(null); + const result = await actions.startThumbnailWarmup({ + mediaSourceId, + missingOnly: true, + size: 256, + }); + if (result.success && result.jobId) { + toast.success(result.message); + setTaggingStatus("Thumbnail warmup in progress..."); + setActiveJobId(result.jobId); + return; + } + if (result.success) { + toast.success(result.message); + setTaggingStatus(result.message); + return; + } + toast.error(result.message || "Failed to start thumbnail warmup."); + setTaggingStatus(result.message || "Failed to start thumbnail warmup."); + } catch (error) { + toast.error(`Error: ${getErrorMessage(error)}`); + setTaggingStatus(`Error: ${getErrorMessage(error)}`); + } + }; + // --- Duplicates --- const setKeepForGroup = (_groupId: string, mediaId: string) => { @@ -748,6 +793,7 @@ export function useManagerPage( handleScan, handleStartBatchTagging, handleStartBatchCcipExtraction, + handleStartThumbnailWarmup, jobHandlers, // Duplicates duplicateSourceId, diff --git a/packages/ui/src/hooks/use-search-page.ts b/packages/ui/src/hooks/use-search-page.ts index 7af6431f2..cc7a1c2ea 100644 --- a/packages/ui/src/hooks/use-search-page.ts +++ b/packages/ui/src/hooks/use-search-page.ts @@ -9,7 +9,12 @@ import type { import type { Project } from "@solid-imager/core/domain/projects/schemas"; import type { SafeMediaSource } from "@solid-imager/core/domain/sources/schemas"; import type { TagResponse } from "@solid-imager/core/domain/tags/schemas"; -import { createInfiniteQuery, createQuery } from "@tanstack/solid-query"; +import { + createInfiniteQuery, + createQuery, + type InfiniteData, + useQueryClient, +} from "@tanstack/solid-query"; import { type Accessor, createEffect, @@ -20,7 +25,7 @@ import { import { isServer } from "solid-js/web"; import { buildSearchResultsQueryOptions } from "../query-options"; import { type QueryUiState, toQueryUiState } from "../query-state"; -import { currentScrollPosition, scrollToPosition } from "./scroll-container"; +import { scrollToPosition, useScrollRestoration } from "./scroll-container"; const DEFAULT_GC_TIME = 1000 * 60 * 5; const DEFAULT_REFRESH_DEBOUNCE_MS = 0; @@ -85,6 +90,9 @@ export interface UseSearchPageResult { typeof createInfiniteQuery >; searchResults: () => MediaSearchResponse["media"]; + hasData: () => boolean; + totalCount: () => number | undefined; + fetchNextPage: () => Promise; contentState: () => QueryUiState; filterStates: { tags: () => QueryUiState; @@ -127,6 +135,13 @@ export function useSearchPage( refreshDebounceMs = DEFAULT_REFRESH_DEBOUNCE_MS, isSearchStateRestored = () => true, } = options; + const queryClient = useQueryClient(); + const tagsQueryKey = queries.tags().queryKey; + const sourcesQueryKey = queries.sources().queryKey; + const projectsQueryKey = queries.projects().queryKey; + const ipsQueryKey = queries.ips().queryKey; + const charactersQueryKey = queries.characters().queryKey; + const authorsQueryKey = queries.authors().queryKey; const tags = createQuery(() => ({ ...queries.tags(), @@ -152,13 +167,52 @@ export function useSearchPage( ...queries.authors(), enabled: !isServer, })); + const [tagsData, setTagsData] = createSignal(); + const [sourcesData, setSourcesData] = createSignal< + SafeMediaSource[] | undefined + >(); + const [projectsData, setProjectsData] = createSignal(); + const [ipsData, setIpsData] = createSignal(); + const [charactersData, setCharactersData] = createSignal< + Character[] | undefined + >(); + const [authorsData, setAuthorsData] = createSignal(); + + createEffect(() => { + tags.dataUpdatedAt; + setTagsData(queryClient.getQueryData(tagsQueryKey)); + }); + createEffect(() => { + sources.dataUpdatedAt; + setSourcesData( + queryClient.getQueryData(sourcesQueryKey), + ); + }); + createEffect(() => { + allProjects.dataUpdatedAt; + setProjectsData(queryClient.getQueryData(projectsQueryKey)); + }); + createEffect(() => { + allIps.dataUpdatedAt; + setIpsData(queryClient.getQueryData(ipsQueryKey)); + }); + createEffect(() => { + allCharacters.dataUpdatedAt; + setCharactersData( + queryClient.getQueryData(charactersQueryKey), + ); + }); + createEffect(() => { + allAuthors.dataUpdatedAt; + setAuthorsData(queryClient.getQueryData(authorsQueryKey)); + }); const conditionKey = createMemo(() => JSON.stringify(getSearchCondition() ?? null), ); - const searchResultQuery = createInfiniteQuery(() => - buildSearchResultsQueryOptions({ + const searchResultQueryOptions = createMemo(() => { + return buildSearchResultsQueryOptions({ mode: mode(), sourceId: selectedSource() || undefined, condition: getSearchCondition(), @@ -172,12 +226,36 @@ export function useSearchPage( searchSimilar: options.searchSimilar, enabled: !isServer && isSearchStateRestored(), gcTime, - }), - ); + }); + }); + const searchResultQuery = createInfiniteQuery(searchResultQueryOptions); + const fetchNextPage = () => + isSearchStateRestored() + ? searchResultQuery.fetchNextPage() + : Promise.resolve(); + const [searchResultData, setSearchResultData] = createSignal< + InfiniteData | undefined + >(); + + createEffect(() => { + // Solid Query exposes `data` as a resource-backed accessor. During an + // infinite-page fetch it can suspend even when previous pages are cached; + // keep the rendered result in a regular signal so the collection DOM stays + // mounted while the next page is loading. + const queryKey = searchResultQueryOptions().queryKey; + searchResultQuery.dataUpdatedAt; + const cachedData = + queryClient.getQueryData>(queryKey); + if (cachedData !== undefined) { + setSearchResultData(cachedData); + } else if (!searchResultQuery.isPlaceholderData) { + setSearchResultData(undefined); + } + }); const searchResults = createMemo(() => { const seen = new Set(); - return (searchResultQuery.data?.pages.flatMap((p) => p.media) || []).filter( + return (searchResultData()?.pages.flatMap((p) => p.media) || []).filter( (m) => { if (seen.has(m.id)) { return false; @@ -190,19 +268,31 @@ export function useSearchPage( const contentState = () => toQueryUiState( { - data: searchResultQuery.data ? searchResults() : undefined, + data: searchResultData() ? searchResults() : undefined, error: searchResultQuery.error, status: searchResultQuery.status, fetchStatus: searchResultQuery.fetchStatus, }, { isEmpty: (data) => data.length === 0 }, ); - const arrayState = (query: { - data: T[] | undefined; - error: unknown; - status: "pending" | "error" | "success"; - fetchStatus: "idle" | "fetching" | "paused"; - }) => toQueryUiState(query, { isEmpty: (data) => data.length === 0 }); + const arrayState = ( + query: { + data: T[] | undefined; + error: unknown; + status: "pending" | "error" | "success"; + fetchStatus: "idle" | "fetching" | "paused"; + }, + data: () => T[] | undefined, + ) => + toQueryUiState( + { + data: data(), + error: query.error, + status: query.status, + fetchStatus: query.fetchStatus, + }, + { isEmpty: (items) => items.length === 0 }, + ); const retryFilters = async () => { await Promise.all([ tags.refetch(), @@ -215,7 +305,7 @@ export function useSearchPage( }; const getSourceRootPath = (mediaSourceId: string) => { - const source = sources.data?.find((item) => item.id === mediaSourceId); + const source = sourcesData()?.find((item) => item.id === mediaSourceId); if (source?.type !== "local") { return undefined; } @@ -247,27 +337,24 @@ export function useSearchPage( ); }; - const [isRestored, setIsRestored] = createSignal(false); - createEffect(() => { - if ( - !(searchResultQuery.isLoading || isRestored()) && - searchResultQuery.data && - searchResultQuery.data.pages.length > 0 - ) { - if (scrollY() > 0) { - requestAnimationFrame(() => { - scrollToPosition(options.scrollContainerSelector, scrollY()); - }); - } - setIsRestored(true); - } + const isRestored = useScrollRestoration({ + restoreKey: () => "search", + getPosition: () => scrollY(), + setPosition: (key, position) => { + void key; + setScrollY(position); + }, + isReady: () => + isSearchStateRestored() && + Boolean(searchResultData()) && + !searchResultQuery.isLoading, + hasNextPage: () => searchResultQuery.hasNextPage, + isFetchingNextPage: () => searchResultQuery.isFetchingNextPage, + fetchNextPage, + scrollContainerSelector: options.scrollContainerSelector, }); onCleanup(() => { - if (!isServer) { - const position = currentScrollPosition(options.scrollContainerSelector); - if (position !== null) setScrollY(position); - } const timer = refreshTimer(); if (timer) { clearTimeout(timer); @@ -292,17 +379,17 @@ export function useSearchPage( const hasNextPage = searchResultQuery.hasNextPage; const isFetching = searchResultQuery.isFetching; - if (!hasNextPage || isFetching) { + if (!isSearchStateRestored() || !hasNextPage || isFetching) { return; } const observer = new IntersectionObserver( (entries) => { - if (entries[0].isIntersecting) { - searchResultQuery.fetchNextPage(); + if (entries[0].isIntersecting && isSearchStateRestored()) { + fetchNextPage(); } }, - { threshold: 0.5, rootMargin: "1000px" }, + { threshold: 0.5, rootMargin: "2400px" }, ); observer.observe(el); onCleanup(() => observer.disconnect()); @@ -311,33 +398,36 @@ export function useSearchPage( return { searchResultQuery, searchResults, + hasData: () => searchResultData() !== undefined, + totalCount: () => searchResultData()?.pages[0]?.total, + fetchNextPage, contentState, filterStates: { - tags: () => arrayState(tags), - sources: () => arrayState(sources), - projects: () => arrayState(allProjects), - ips: () => arrayState(allIps), - characters: () => arrayState(allCharacters), - authors: () => arrayState(allAuthors), + tags: () => arrayState(tags, tagsData), + sources: () => arrayState(sources, sourcesData), + projects: () => arrayState(allProjects, projectsData), + ips: () => arrayState(allIps, ipsData), + characters: () => arrayState(allCharacters, charactersData), + authors: () => arrayState(allAuthors, authorsData), }, filterData: { get tags() { - return tags.data; + return tagsData(); }, get projects() { - return allProjects.data; + return projectsData(); }, get ips() { - return allIps.data; + return ipsData(); }, get characters() { - return allCharacters.data; + return charactersData(); }, get authors() { - return allAuthors.data; + return authorsData(); }, }, - sources: () => sources.data, + sources: sourcesData, getSourceRootPath, isRestored, handleSearch, diff --git a/packages/ui/src/hooks/use-source-media-page.ts b/packages/ui/src/hooks/use-source-media-page.ts index e46623235..6987ac881 100644 --- a/packages/ui/src/hooks/use-source-media-page.ts +++ b/packages/ui/src/hooks/use-source-media-page.ts @@ -36,12 +36,11 @@ import { type QueryUiState, toQueryUiState } from "../query-state"; import type { PresetManagerClient } from "../search-control-panel"; import { toast } from "../toast"; import { getRestoreImportStrategies } from "./restore-import"; -import { currentScrollPosition, scrollToPosition } from "./scroll-container"; +import { scrollToPosition, useScrollRestoration } from "./scroll-container"; import type { MediaSourceEventTransport } from "./use-media-source-events"; import { useMediaSourceEvents } from "./use-media-source-events"; export const SOURCE_MEDIA_ITEMS_PER_PAGE = 200; -const SCROLL_RESTORE_DELAY = 100; const DEBOUNCE_DELAY_MS = 1000; const MEDIA_REFRESH_DEBOUNCE_MS = 300; @@ -178,6 +177,8 @@ export type UseSourceMediaPageResult = { mediaSourceId: () => string | undefined; mediaQuery: ReturnType>; mediaResults: () => MediaSearchResponse["media"]; + hasData: () => boolean; + totalCount: () => number | undefined; contentState: () => QueryUiState; filterData: () => SourceMediaPageFilterData; filterStates: () => SourceMediaPageQueryStates; @@ -256,7 +257,7 @@ export function useSourceMediaPage( JSON.stringify(getSearchCondition() ?? null), ); - const mediaQuery = createInfiniteQuery(() => + const mediaQueryOptions = createMemo(() => buildSourceMediaResultsQueryOptions({ sourceId: id(), condition: getSearchCondition(), @@ -268,11 +269,29 @@ export function useSourceMediaPage( enabled: !isServer && isSearchStateRestored() && !!id(), }), ); + const mediaQuery = createInfiniteQuery(mediaQueryOptions); + const [mediaQueryData, setMediaQueryData] = createSignal< + InfiniteData | undefined + >(); + + createEffect(() => { + // Keep paginated results outside Solid Query's resource-backed `data` + // accessor. A page fetch must not suspend and remount the media grid. + const queryKey = mediaQueryOptions().queryKey; + void mediaQuery.dataUpdatedAt; + const cachedData = + queryClient.getQueryData>(queryKey); + if (cachedData !== undefined) { + setMediaQueryData(cachedData); + } else if (!mediaQuery.isPlaceholderData) { + setMediaQueryData(undefined); + } + }); // --- Deduplicated results --- const mediaResults = createMemo(() => { const seen = new Set(); - return (mediaQuery.data?.pages.flatMap((page) => page.media) || []).filter( + return (mediaQueryData()?.pages.flatMap((page) => page.media) || []).filter( (media) => { if (seen.has(media.id)) { return false; @@ -285,7 +304,7 @@ export function useSourceMediaPage( const contentState = () => toQueryUiState( { - data: mediaQuery.data ? mediaResults() : undefined, + data: mediaQueryData() ? mediaResults() : undefined, error: mediaQuery.error, status: mediaQuery.status, fetchStatus: mediaQuery.fetchStatus, @@ -295,55 +314,21 @@ export function useSourceMediaPage( // --- Search handler --- const handleSearch = () => { + const sourceId = id(); + if (sourceId) setScrollPosition(sourceId, 0); scrollToPosition(options.scrollContainerSelector, 0); }; // --- Scroll restoration --- - const [isScrollRestored, setIsScrollRestored] = createSignal(false); - - onMount(() => { - if ("scrollRestoration" in history) { - history.scrollRestoration = "manual"; - } - }); - - createEffect(() => { - if (isServer) { - return; - } - if (isScrollRestored()) { - return; - } - - const sourceId = id(); - if (!sourceId) { - return; - } - - if (mediaQuery.data && !mediaQuery.isLoading) { - const targetScrollY = getScrollPosition(sourceId); - if (targetScrollY > 0) { - setTimeout(() => { - requestAnimationFrame(() => { - scrollToPosition(options.scrollContainerSelector, targetScrollY); - setIsScrollRestored(true); - }); - }, SCROLL_RESTORE_DELAY); - } else { - setIsScrollRestored(true); - } - } - }); - - onCleanup(() => { - if (isServer) { - return; - } - const sourceId = id(); - if (sourceId) { - const position = currentScrollPosition(options.scrollContainerSelector); - if (position !== null) setScrollPosition(sourceId, position); - } + useScrollRestoration({ + restoreKey: id, + getPosition: (sourceId) => getScrollPosition(sourceId), + setPosition: (sourceId, position) => setScrollPosition(sourceId, position), + isReady: () => Boolean(mediaQueryData()) && !mediaQuery.isLoading, + hasNextPage: () => mediaQuery.hasNextPage, + isFetchingNextPage: () => mediaQuery.isFetchingNextPage, + fetchNextPage: () => mediaQuery.fetchNextPage(), + scrollContainerSelector: options.scrollContainerSelector, }); // --- Infinite scroll --- @@ -367,7 +352,7 @@ export function useSourceMediaPage( mediaQuery.fetchNextPage(); } }, - { threshold: 0.5, rootMargin: "1000px" }, + { threshold: 0.5, rootMargin: "2400px" }, ); observer.observe(el); @@ -943,7 +928,7 @@ export function useSourceMediaPage( }; const handleSyncLoadedMedia = async () => { - const allPages = mediaQuery.data?.pages; + const allPages = mediaQueryData()?.pages; if (!allPages || isSyncingMedia()) { return; } @@ -981,6 +966,8 @@ export function useSourceMediaPage( mediaSourceId: id, mediaQuery, mediaResults, + hasData: () => mediaQueryData() !== undefined, + totalCount: () => mediaQueryData()?.pages[0]?.total, contentState, filterData, filterStates: queryStates, diff --git a/packages/ui/src/media-grid-item.tsx b/packages/ui/src/media-grid-item.tsx index 12c400d62..454f0a37f 100644 --- a/packages/ui/src/media-grid-item.tsx +++ b/packages/ui/src/media-grid-item.tsx @@ -3,12 +3,24 @@ import type { JSX } from "solid-js"; import { Show } from "solid-js"; import { cn } from "./utils/cn"; +export const V2_MEDIA_GRID_IMAGE_SIZES = + "(min-width: 1536px) 12vw, (min-width: 1120px) 14vw, (min-width: 960px) 17vw, (min-width: 640px) 25vw, 50vw"; + +export type MediaGridImageLoadPolicy = { + enabled?: boolean; + fetchpriority?: "high" | "low" | "auto"; + loading?: "eager" | "lazy"; +}; + export type MediaGridThumbnailProps = { alt: string; class: string; + enabled?: boolean; + fetchpriority?: "high" | "low" | "auto"; height?: number | null; loading: "eager" | "lazy"; media: Media; + sizes?: string; sourceRootPath?: string; width?: number | null; }; @@ -26,6 +38,7 @@ export type MediaGridLinkProps = { type MediaGridItemProps = { variant?: "default" | "v2"; media: Media; + imageLoadPolicy?: MediaGridImageLoadPolicy; linkPrefix?: string; priority?: boolean; sourceRootPath?: string; @@ -104,8 +117,20 @@ export function MediaGridItem(props: MediaGridItemProps) { props.thumbnailClass, ), height: props.media.height, - loading: props.priority ? "eager" : "lazy", + get enabled() { + return props.imageLoadPolicy?.enabled; + }, + get fetchpriority() { + return props.imageLoadPolicy?.fetchpriority; + }, + get loading() { + return ( + props.imageLoadPolicy?.loading ?? + (props.priority ? "eager" : "lazy") + ); + }, media: props.media, + sizes: props.variant === "v2" ? V2_MEDIA_GRID_IMAGE_SIZES : undefined, sourceRootPath: props.sourceRootPath, width: props.media.width, })} diff --git a/packages/ui/src/query-options/search-query.test.ts b/packages/ui/src/query-options/search-query.test.ts index 3d6e9357d..b8d49143a 100644 --- a/packages/ui/src/query-options/search-query.test.ts +++ b/packages/ui/src/query-options/search-query.test.ts @@ -9,6 +9,7 @@ import { buildSearchResultsQueryOptions, buildSourceMediaResultsQueryOptions, isSourceMediaResultsQueryKey, + MEDIA_RESULTS_STALE_TIME_MS, removeMediaFromInfiniteQueryData, searchQueryKeys, sourceMediaQueryKeys, @@ -160,6 +161,7 @@ describe("buildSearchResultsQueryOptions", () => { expect(options.initialPageParam).toBe(0); expect(options.enabled).toBe(false); expect(options.gcTime).toBe(12_345); + expect(options.staleTime).toBe(MEDIA_RESULTS_STALE_TIME_MS); const queryFn = options.queryFn; if (typeof queryFn !== "function") { @@ -346,6 +348,7 @@ describe("buildSourceMediaResultsQueryOptions", () => { ]); expect(options.initialPageParam).toBe(0); expect(options.enabled).toBe(true); + expect(options.staleTime).toBe(MEDIA_RESULTS_STALE_TIME_MS); const queryFn = options.queryFn; if (typeof queryFn !== "function") { diff --git a/packages/ui/src/query-options/search-query.ts b/packages/ui/src/query-options/search-query.ts index 17d9e8074..4b7b3bfb3 100644 --- a/packages/ui/src/query-options/search-query.ts +++ b/packages/ui/src/query-options/search-query.ts @@ -8,6 +8,8 @@ import { keepPreviousData, } from "@tanstack/solid-query"; +export const MEDIA_RESULTS_STALE_TIME_MS = 1000 * 60 * 5; + export type SearchResultsQueryKeyInput = { mode: "simple" | "pro" | "vector"; sourceId: string | undefined; @@ -187,6 +189,7 @@ export function buildSearchResultsQueryOptions( placeholderData: input.mode === "vector" ? undefined : keepPreviousData, enabled: input.enabled, gcTime: input.gcTime, + staleTime: MEDIA_RESULTS_STALE_TIME_MS, }); } @@ -238,5 +241,6 @@ export function buildSourceMediaResultsQueryOptions( }, placeholderData: keepPreviousData, enabled: input.enabled, + staleTime: MEDIA_RESULTS_STALE_TIME_MS, }); } diff --git a/packages/ui/src/screens/manager-screen.tsx b/packages/ui/src/screens/manager-screen.tsx index 596fce048..4b52c1c46 100644 --- a/packages/ui/src/screens/manager-screen.tsx +++ b/packages/ui/src/screens/manager-screen.tsx @@ -78,6 +78,7 @@ const managerTabs: ManagerEntityType[] = [ "characters", "tagging", "vectors", + "thumbnails", "duplicates", ]; @@ -88,6 +89,9 @@ function tabLabel(tab: ManagerEntityType) { if (tab === "vectors") { return "Vector Extraction"; } + if (tab === "thumbnails") { + return "Thumbnails"; + } if (tab === "ips") { return "IPs"; } @@ -358,6 +362,96 @@ export function ManagerScreen(props: ManagerScreenProps) {
+ + + + Thumbnail Warmup + + Generate only missing 256px thumbnails used by dense media grids. + + + +
+ + +

+ Existing 512px previews are preserved. Only missing 256px + variants are queued. +

+
+ + +
+ {manager().taggingStatus()} +
+
+ + {(progress) => ( +
+
+ Batch progress + + {progress().processed} / {progress().total} + +
+ 0 + ? (progress().processed / progress().total) * 100 + : 0 + } + /> +
+ )} +
+
+
+
+ diff --git a/packages/ui/src/screens/search-screen.tsx b/packages/ui/src/screens/search-screen.tsx index 404f76e9c..41ae45a6e 100644 --- a/packages/ui/src/screens/search-screen.tsx +++ b/packages/ui/src/screens/search-screen.tsx @@ -10,6 +10,7 @@ import type { UseSearchPageResult, } from "../hooks/use-search-page"; import type { SourceMediaPagePresetClient } from "../hooks/use-source-media-page"; +import type { MediaGridImageLoadPolicy } from "../media-grid-item"; import { MobileSearchFilterDialog } from "../mobile-search-filter-dialog"; import { SearchControlPanel } from "../search-control-panel"; import { LoadingRegion, MediaGridSkeleton } from "../skeleton"; @@ -31,7 +32,12 @@ export type SearchScreenProps = { renderNavActions?: (actions: SearchScreenNavActions) => JSX.Element; renderMediaItem: ( media: Media, - options?: { onPreviewSelect?: () => void; isPreviewSelected?: boolean }, + options?: { + imageLoadPolicy?: MediaGridImageLoadPolicy; + onPreviewSelect?: () => void; + isPreviewSelected?: boolean; + priority?: boolean; + }, ) => JSX.Element; renderMediaPreview?: (media: Media) => JSX.Element; onOpenMediaDetail?: (media: Media) => void; @@ -143,17 +149,19 @@ export function SearchScreen(props: SearchScreenProps) { isFetchingNextPage={page().searchResultQuery.isFetchingNextPage} mediaResults={page().searchResults} mediaSourceId={() => undefined} - onLoadMore={() => page().searchResultQuery.fetchNextPage()} + onLoadMore={page().fetchNextPage} onRetry={async () => { await page().searchResultQuery.refetch(); }} hasNextPage={page().searchResultQuery.hasNextPage} - renderItem={(media, _options) => props.renderMediaItem(media)} + renderItem={(media, options) => + props.renderMediaItem(media, options) + } setLoadMoreRef={page().setLoadMoreRef} showEmptyState showResultCount state={page().contentState} - totalCount={page().searchResultQuery.data?.pages[0]?.total} + totalCount={page().totalCount()} /> diff --git a/packages/ui/src/screens/source-media-screen.tsx b/packages/ui/src/screens/source-media-screen.tsx index 702fdfcad..a8e58a501 100644 --- a/packages/ui/src/screens/source-media-screen.tsx +++ b/packages/ui/src/screens/source-media-screen.tsx @@ -17,6 +17,7 @@ import type { UploadOptions, UseSourceMediaPageResult, } from "../hooks/use-source-media-page"; +import type { MediaGridImageLoadPolicy } from "../media-grid-item"; import { MobileSearchFilterDialog } from "../mobile-search-filter-dialog"; import { SearchControlPanel } from "../search-control-panel"; import { LoadingRegion, MediaGridSkeleton } from "../skeleton"; @@ -33,7 +34,9 @@ export type SourceMediaScreenProps = { renderItem: ( media: Media, options: { + imageLoadPolicy?: MediaGridImageLoadPolicy; onContextMenu: () => void; + priority?: boolean; isBulkSelectMode?: boolean; isSelected?: boolean; onPreviewSelect?: () => void; @@ -123,9 +126,9 @@ export function SourceMediaScreen(props: SourceMediaScreenProps) {

{props.mediaSourceName?.() ?? "メディア一覧"}

- +

- {page().mediaQuery.data?.pages[0]?.total} 件の結果 + {page().totalCount()} 件の結果

@@ -141,9 +144,7 @@ export function SourceMediaScreen(props: SourceMediaScreenProps) {